Compare commits
171 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b1d7ed2570 | |||
| dcbef721f2 | |||
| 772f8a615a | |||
| bf9ae20367 | |||
| 58d1f9426f | |||
| 3cd31c4322 | |||
| ae5feb05b7 | |||
| f2c4927ea6 | |||
| aa2abc2772 | |||
| 1aa84afa9a | |||
| 33e9118329 | |||
| e06fd57211 | |||
| 42fd3c7e90 | |||
| 0bc71dbd74 | |||
| 2ecd830d75 | |||
| 3a51b0ebd4 | |||
| ad406f21bd | |||
| 12fb9fc38b | |||
| 7b580a0070 | |||
| 22443a3810 | |||
| 0fce1e521c | |||
| 71fcf5e251 | |||
| 979e71fbea | |||
| 45e0b0bd95 | |||
| 70eb3fc13f | |||
| b30aa352f6 | |||
| 67cc33cfee | |||
| 6eec3b9ec7 | |||
| 57773b98ec | |||
| fe9b899a0e | |||
| abe9e663c1 | |||
| f5a33eb58c | |||
| a85090c3c6 | |||
| 97d48d8371 | |||
| 5020137050 | |||
| cf05ab2a9e | |||
| a6416a3f1d | |||
| 47ced228de | |||
| b8beeba98d | |||
| df6994c957 | |||
| d74e86c065 | |||
| 76512f6048 | |||
| 93a46d4de7 | |||
| 3ba24a0faf | |||
| 6926bb9528 | |||
| b1643309f6 | |||
| 43917a0051 | |||
| 1fac71e3ef | |||
| 747cc234c1 | |||
| fe72f0def2 | |||
| 884ecbba64 | |||
| 580d80a86e | |||
| 0e2ca5a7c3 | |||
| 4d2b8b9be3 | |||
| 0b31abe1d1 | |||
| 6b652cb0a2 | |||
| 3507c5714d | |||
| 4f78b9a875 | |||
| f3aebafcc7 | |||
| 1df0bc4f00 | |||
| 7d5d0cff06 | |||
| 8d752cb0e4 | |||
| 96e80ab293 | |||
| 49c5185ae3 | |||
| 181bd94341 | |||
| 09675a9f7f | |||
| 56c364e4c9 | |||
| 3021a4e761 | |||
| d240a61157 | |||
| d7c5307045 | |||
| 838d76f95e | |||
| 9791eee67b | |||
| 5d119f5555 | |||
| 46e5f612c8 | |||
| 41494bd18f | |||
| 40ebf7c1e7 | |||
| c7609252d2 | |||
| 4cf388dd3b | |||
| 00d85aa6e4 | |||
| d9e80a774a | |||
| a82407c686 | |||
| 805d754dc8 | |||
| d4c3811665 | |||
| b25761ea31 | |||
| 1c396dd562 | |||
| fc29c2eb9b | |||
| b0d5e04bb9 | |||
| 6746c75302 | |||
| b2697b13dc | |||
| e8ee6c34e7 | |||
| f42279f869 | |||
| 54ad9e8f79 | |||
| 6f16a231fc | |||
| 626c972232 | |||
| 44fcf24d92 | |||
| e187cd49a2 | |||
| 1631d3b1a2 | |||
| c71c2a8d33 | |||
| a51947562c | |||
| 63f02c4fb1 | |||
| 4fd5ee2608 | |||
| c7d4b9f753 | |||
| 37c2e5d7ee | |||
| 7dbd878398 | |||
| c2e2e0d8f2 | |||
| afc909fd3b | |||
| b607ff28cb | |||
| cf86d4e425 | |||
| 85761390a8 | |||
| 4d30d8b3e8 | |||
| 0e30980632 | |||
| 46a81e7a07 | |||
| e09344490f | |||
| 80a8bc4520 | |||
| b50e0359f7 | |||
| 58a300c7f4 | |||
| eb8311a5ee | |||
| 550a59d12c | |||
| 8c1d1ed958 | |||
| fc00b0c6f9 | |||
| 5276dd2066 | |||
| 88da16a11e | |||
| 3ef3bc32ec | |||
| 36fe1caa3f | |||
| f55c401b6c | |||
| b8037b9b22 | |||
| c0a3f68ded | |||
| 04c5043aba | |||
| 0b66662525 | |||
| cdea85e214 | |||
| 05f6147433 | |||
| 8f3b659c33 | |||
| c9ae914910 | |||
| 84e81f2037 | |||
| 696386a9c1 | |||
| aa2679162d | |||
| 096d1c882f | |||
| ca63095786 | |||
| c65e9c54ce | |||
| b1bc7a764e | |||
| d7c0a5521d | |||
| 2ae90b1ea9 | |||
| cfb0435d96 | |||
| fc411bb6f1 | |||
| 5bc39e902d | |||
| e2c4ca6d56 | |||
| 288d990821 | |||
| c03702707b | |||
| 89f77470f3 | |||
| 0d576a14b7 | |||
| c5807c07a9 | |||
| 8f5f54833f | |||
| f451406058 | |||
| 8aab2c0d41 | |||
| ed0ccb8c2b | |||
| b40adac3fc | |||
| bfb7876ed4 | |||
| 55b4e54d5f | |||
| fafa2f1858 | |||
| c84fd14cac | |||
| 23374312bc | |||
| a84a72e0c0 | |||
| 6c102f00c0 | |||
| 48aa955212 | |||
| cf3ddde3a6 | |||
| 468b006008 | |||
| e091921b18 | |||
| a9eb54ae9c | |||
| cf961603fe | |||
| cc3ecddc06 | |||
| a9a816e0ed |
@@ -30,3 +30,9 @@ Thumbs.db
|
||||
|
||||
# Frozen baseline archives / inspects / manifests
|
||||
/docker-backups/
|
||||
gate-evidence/
|
||||
|
||||
# Raw Fire2 frame captures — forensic evidence, may contain session material.
|
||||
# Sanitize with `blaze-sanitize` before anything leaves this machine.
|
||||
*.ofcap
|
||||
captures/
|
||||
|
||||
Generated
+241
-125
@@ -114,17 +114,6 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "aes"
|
||||
version = "0.8.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ahash"
|
||||
version = "0.8.12"
|
||||
@@ -457,6 +446,29 @@ version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.44.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum"
|
||||
version = "0.7.9"
|
||||
@@ -613,19 +625,6 @@ dependencies = [
|
||||
"tokio-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blaze-ssl-async"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fec08f35919613bda0b3eb3bc772c2f793b3634133923b931874b18e1ac55de"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"num_enum",
|
||||
"rsa",
|
||||
"tokio",
|
||||
"x509-cert",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block"
|
||||
version = "0.1.6"
|
||||
@@ -811,16 +810,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cipher"
|
||||
version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"inout",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
@@ -830,6 +819,15 @@ dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.58"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codespan-reporting"
|
||||
version = "0.11.1"
|
||||
@@ -1062,23 +1060,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"der_derive",
|
||||
"flagset",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der_derive"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@@ -1187,6 +1172,12 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "ecolor"
|
||||
version = "0.29.1"
|
||||
@@ -1457,12 +1448,6 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "flagset"
|
||||
version = "0.4.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
@@ -1547,6 +1532,12 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures-channel"
|
||||
version = "0.3.33"
|
||||
@@ -2113,9 +2104,9 @@ dependencies = [
|
||||
"futures-util",
|
||||
"http 0.2.12",
|
||||
"hyper 0.14.32",
|
||||
"rustls",
|
||||
"rustls 0.21.12",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-rustls 0.24.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2316,15 +2307,6 @@ dependencies = [
|
||||
"hashbrown 0.17.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ipnet"
|
||||
version = "2.12.1"
|
||||
@@ -3126,11 +3108,30 @@ version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "openfut-adapter-fifa17"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openfut-protocol-blaze",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-blaze-host"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openfut-adapter-fifa17",
|
||||
"openfut-protocol-blaze",
|
||||
"rand",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-bridge"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"anyhow",
|
||||
"axum",
|
||||
"bytes",
|
||||
@@ -3141,13 +3142,13 @@ dependencies = [
|
||||
"hyper-util",
|
||||
"rcgen",
|
||||
"reqwest",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls 0.21.12",
|
||||
"rustls-pemfile 1.0.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-rustls 0.24.1",
|
||||
"tokio-stream",
|
||||
"tower 0.4.13",
|
||||
"tower-http",
|
||||
@@ -3156,6 +3157,10 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-common"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "openfut-core"
|
||||
version = "0.1.0"
|
||||
@@ -3181,10 +3186,40 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-hook"
|
||||
name = "openfut-host-config"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
"openfut-adapter-fifa17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-http"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "openfut-identity"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-import-fifa17"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"openfut-adapter-fifa17",
|
||||
"openfut-core",
|
||||
"openfut-identity",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3196,11 +3231,64 @@ dependencies = [
|
||||
"dirs",
|
||||
"eframe",
|
||||
"egui",
|
||||
"openfut-common",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-protocol-blaze"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-redirector-host"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openfut-adapter-fifa17",
|
||||
"openfut-host-config",
|
||||
"openfut-http",
|
||||
"openfut-tls",
|
||||
"openssl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-roster-host"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openfut-adapter-fifa17",
|
||||
"openfut-host-config",
|
||||
"openfut-http",
|
||||
"openfut-tls",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-tls"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"openssl",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openfut-utas-host"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"axum",
|
||||
"openfut-adapter-fifa17",
|
||||
"openfut-core",
|
||||
"openfut-http",
|
||||
"openfut-identity",
|
||||
"parking_lot",
|
||||
"rand",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.81"
|
||||
@@ -3232,6 +3320,15 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-src"
|
||||
version = "300.6.1+3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.117"
|
||||
@@ -3240,6 +3337,7 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"openssl-src",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
@@ -3680,8 +3778,8 @@ dependencies = [
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls 0.21.12",
|
||||
"rustls-pemfile 1.0.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
@@ -3689,7 +3787,7 @@ dependencies = [
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-rustls 0.24.1",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
@@ -3828,10 +3926,26 @@ checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
|
||||
dependencies = [
|
||||
"log",
|
||||
"ring 0.17.14",
|
||||
"rustls-webpki",
|
||||
"rustls-webpki 0.101.7",
|
||||
"sct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"log",
|
||||
"once_cell",
|
||||
"ring 0.17.14",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki 0.103.13",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "1.0.4"
|
||||
@@ -3841,6 +3955,24 @@ dependencies = [
|
||||
"base64 0.21.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
|
||||
dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.101.7"
|
||||
@@ -3851,6 +3983,18 @@ dependencies = [
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring 0.17.14",
|
||||
"rustls-pki-types",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
@@ -4037,15 +4181,17 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"blaze-proto",
|
||||
"blaze-ssl-async",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"rustls 0.23.43",
|
||||
"rustls-pemfile 2.2.0",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tdf",
|
||||
"tokio",
|
||||
"tokio-rustls 0.26.4",
|
||||
"tokio-util",
|
||||
"toml",
|
||||
"tracing",
|
||||
@@ -4063,6 +4209,12 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1_smol"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d"
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.10.9"
|
||||
@@ -4329,8 +4481,8 @@ dependencies = [
|
||||
"once_cell",
|
||||
"paste",
|
||||
"percent-encoding",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls 0.21.12",
|
||||
"rustls-pemfile 1.0.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
@@ -4784,27 +4936,6 @@ version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tls_codec"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b"
|
||||
dependencies = [
|
||||
"tls_codec_derive",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tls_codec_derive"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.53.1"
|
||||
@@ -4849,7 +4980,17 @@ version = "0.24.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"rustls 0.21.12",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
|
||||
dependencies = [
|
||||
"rustls 0.23.43",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
@@ -5214,6 +5355,7 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"sha1_smol",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -6152,18 +6294,6 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "x509-cert"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"der",
|
||||
"spki",
|
||||
"tls_codec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xcursor"
|
||||
version = "0.3.11"
|
||||
@@ -6388,20 +6518,6 @@ name = "zeroize"
|
||||
version = "1.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
|
||||
dependencies = [
|
||||
"zeroize_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zeroize_derive"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerotrie"
|
||||
|
||||
+19
-1
@@ -2,9 +2,27 @@
|
||||
resolver = "2"
|
||||
members = [
|
||||
"openfut-core",
|
||||
"openfut-protocol-blaze",
|
||||
"openfut-adapter-fifa17",
|
||||
"openfut-blaze-host",
|
||||
"openfut-host-config",
|
||||
"openfut-http",
|
||||
"openfut-tls",
|
||||
"openfut-redirector-host",
|
||||
"openfut-roster-host",
|
||||
"openfut-utas-host",
|
||||
"openfut-identity",
|
||||
"openfut-import-fifa17",
|
||||
"openfut-bridge",
|
||||
"openfut-launcher",
|
||||
"openfut-launcher/openfut-hook",
|
||||
"fifa-blaze/crates/blaze-proto",
|
||||
"fifa-blaze/crates/server",
|
||||
]
|
||||
# openfut-hook is a Windows-only version.dll proxy injected into the FIFA client.
|
||||
# It MUST build with its own [profile.release] (panic="abort" — unwinding across
|
||||
# the DllMain/FFI boundary into the game process is UB — plus strip + opt-level="s").
|
||||
# Cargo ignores a non-root member's profile and forbids per-package `panic` overrides,
|
||||
# so the hook is deliberately EXCLUDED from this workspace to build as its own root
|
||||
# (this also lands its artifact in openfut-hook/target/, matching the launcher's
|
||||
# config.rs default hook_dll_path). Build: cargo build --release --target x86_64-pc-windows-gnu.
|
||||
exclude = ["openfut-launcher/openfut-hook"]
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
# FIFA 17 FUT — Card Taxonomy (single source of truth)
|
||||
|
||||
Status: verified 2026-08-12. This document supersedes every earlier scattered
|
||||
taxonomy claim in the repo (see "Superseded taxonomy" at the bottom).
|
||||
|
||||
Evidence labels: **OBSERVED** = read directly from a shipped table or decompiled
|
||||
function; **INFERRED** = derived from OBSERVED facts; **HYPOTHESIS** = plausible,
|
||||
not yet proven. `UNKNOWN` is a valid answer and is stated as such.
|
||||
|
||||
## Provenance
|
||||
|
||||
- Authoritative tables: `10.10.0.105:/home/alex/Documents/OpenFUT/fifa17-recon/data/tables/`,
|
||||
copied byte-identical into this repo at `fifa17-recon/data/tables/`.
|
||||
SHA-256 manifest + verification: `docs/evidence/fifa17-recon/table-hashes.sha256`
|
||||
(36 files: 31 `fcc_*.json` + 5 staff tables; combined hash-of-hashes
|
||||
`10f239add919089354d8dbff873fc9737b0a0f80f6ac41b1aa2a096c0ec8d331`).
|
||||
- Each table file is a DECODED dump `{table, source, rowcount, schema[], rows[]}`;
|
||||
card instances are in `rows[]`, one object per card. Row counts and id ranges
|
||||
below were re-extracted from the in-repo `.120` copies.
|
||||
- Semantic (subtype→kind) labels are decompiler-derived, carried in
|
||||
`fifa17-recon/tools/fut_consumables.py` (`BY_SUBTYPE`, `_DOC`), generated by
|
||||
`tools/build_consumables.py` from `FUN_1800d8330`, `FUN_18013f4d0`,
|
||||
`FUN_1801bfac0`, `FUN_1801aa230`, `FUN_180048780` + the fcc tables.
|
||||
- Staff family selection: `fifa17-recon/docs/CARD_SYSTEM.md` (2026-08-04/05 blocks)
|
||||
and `fifa17-recon/tools/fut_staff.py` / `fut_coaches.py`.
|
||||
|
||||
## Family selector (OBSERVED, binary)
|
||||
|
||||
Before registering an item, `FUN_180141660` merges the client's local DB. It
|
||||
switches on record `+0x4c` (`cardtype`), which `FUN_1800d8330` derives from JSON
|
||||
atom `0x6c cardsubtypeid` alone:
|
||||
|
||||
```
|
||||
cardsubtypeid 0..3 -> cardtype 1 players
|
||||
4 -> cardtype 2 managercards
|
||||
5 -> cardtype 3 headcoachcards
|
||||
6 -> cardtype 10 gkcoachcards
|
||||
7 -> cardtype 5 physiocards
|
||||
8 -> cardtype 4 fitnesscoachcards
|
||||
0x1e,0x1f,0x91..0x96,0xe7..0xe9,0xec -> cardtype 9 club items + misc
|
||||
absent (default 342) -> cardtype 0 no merge
|
||||
```
|
||||
|
||||
Key: `carddbid == resourceId` for non-player families (queried RAW, no mask);
|
||||
players are queried by `playerid = resourceId & 0xffffff` (atom `0x287`), and
|
||||
`assetId` (atom `0x23`) is never read by the merge.
|
||||
|
||||
## Consumable & staff families — evidence table (OBSERVED, `.120` tables)
|
||||
|
||||
| Family | Table | Rows | carddbid range | cardsubtype set | cardassetid (ART) |
|
||||
|---|---|---|---|---|---|
|
||||
| Contracts | `fcc_contractcards.json` | 13 | 5001001–5001013 | {201, 202} | {7, 8} |
|
||||
| Fitness + Healing | `fcc_healingcards.json` | 27 | 5002001–5002030 | {211,212,213,215,216,217,218,219,220} | {9, 10} |
|
||||
| Training (6 sub-families) | `fcc_trainingcards.json` | 143 | 5003001–5003159 | 51-57, 61-67, 91-110, 121-136, 250-273, 300-340 | {1,3,32,34,35,50,51} |
|
||||
| Misc | `fcc_misccards.json` | 42 | 5004001–5004042 | {231, 232, 233, 236} | {43, 44, 45, 46} |
|
||||
| Badges | `fcc_badgecards.json` | 656 | 6000000–6000656 | (none in row) | {39} |
|
||||
| Stadiums | `fcc_stadium.json` | 78 | 6200000–6200077 | (none in row) | {36} |
|
||||
| Kits | `fcc_kitcards.json` | 1482 | 6300000–6400654 | (none in row) | {35} |
|
||||
| League logos | `fcc_leaguelogos.json` | 44 | 8010000–8010044 | (none in row) | {40} |
|
||||
| League logo stickers | `fcc_leaguelogostickers.json` | 39 | 8010000–8010039 | (none in row) | {40} |
|
||||
| Balls | `fcc_balls.json` | 42 | 8120194–8120236 | (none in row) | {37} |
|
||||
| Managers | `managercards.json` | 417 | 1000001–1001552 | (staff; by cardsubtypeid 4) | (assetid col) |
|
||||
| Head coaches | `headcoachcards.json` | 124 | 2000004–2000328 | (staff; subtype 5) | — |
|
||||
| GK coaches | `gkcoachcards.json` | 121 | 9000001–9000324 | (staff; subtype 6) | — |
|
||||
| Physios | `physiocards.json` | 51 | 4000002–4000259 | (staff; subtype 7) | — |
|
||||
| Fitness coaches | `fitnesscoachcards.json` | 115 | 3000019–3000328 | (staff; subtype 8) | — |
|
||||
|
||||
Notes:
|
||||
- **Contracts (5001xxx)** — 201 = player_contract, 202 = manager_contract
|
||||
(OBSERVED, `BY_SUBTYPE`).
|
||||
- **Fitness + Healing (5002xxx)** — subtype **214 is a valid enum value but ships
|
||||
ZERO rows** (OBSERVED: 214 absent from `rows[]`). Enum split (OBSERVED,
|
||||
`BY_SUBTYPE`): healing = 211-218, player_fitness = 219, squad_fitness = 220.
|
||||
The `+0x58 rareflag == 1` trap flips subtype 219 to squad-fitness.
|
||||
- **Training (5003xxx)** — SIX sub-families, all OBSERVED in `rows[]` and labelled
|
||||
in `BY_SUBTYPE`:
|
||||
- `gk_training` 51-57 (7)
|
||||
- `player_training` 61-67 (7)
|
||||
- `position_mod` 91-110 (20)
|
||||
- `formation_mod` 121-136 (16)
|
||||
- chem/play styles 250-273 (24): `player_playstyle` 250-268 (19) +
|
||||
`gk_playstyle` 269-273 (5)
|
||||
- `manager_league` 300-**341** in the client enum (42), but the shipped table
|
||||
contains only 300-340 (41 rows) — 341 is defined, not shipped.
|
||||
- Client enum also defines vestigial/unshipped ranges NOT in the table:
|
||||
`manager_formation_mod` 71-86, and `DEAD_ZONE`
|
||||
58-60,68-70,87-90,111-120,203-210 (28). These have no rows.
|
||||
- **Misc (5004xxx)** — subtypes {231,232,233,236}; cardassetid ART 43-46.
|
||||
cardsubtype 231 = 0xe7 anchors the 0xe7..0xe9 block to misc via `FUN_1800d8330`.
|
||||
- **Club items (badges/stadiums/kits/logos/balls)** carry no `cardsubtype` in the
|
||||
row; they are keyed by `carddbid` range and distinguished on the wire by ART
|
||||
`cardassetid` (badges 39, stadium 36, kits 35, logos 40, balls 37). **Kits live
|
||||
at 6300000–6400654 and are NOT badges** (badges are 6000xxx). The client-side
|
||||
subtype→family map (which of 0x1e,0x1f,0x91..0x96 is ball vs stadium vs badge vs
|
||||
kit) is **UNKNOWN** — it is in none of the dumped tables and cardtype 9 has no
|
||||
miss-fill arm (see `CARD_SYSTEM.md`, "Club items", 2026-08-05).
|
||||
|
||||
## Staff — subtype/cardtype selectors (OBSERVED, binary)
|
||||
|
||||
| cardsubtypeid | cardtype | Family | Table | Rows | carddbid range |
|
||||
|---|---|---|---|---|---|
|
||||
| 4 | 2 | Manager | `managercards.json` | 417 | 1000001–1001552 |
|
||||
| 5 | 3 | Head coach | `headcoachcards.json` | 124 | 2000004–2000328 |
|
||||
| 6 | 10 | GK coach | `gkcoachcards.json` | 121 | 9000001–9000324 |
|
||||
| 7 | 5 | Physio | `physiocards.json` | 51 | 4000002–4000259 |
|
||||
| 8 | 4 | Fitness coach | `fitnesscoachcards.json` | 115 | 3000019–3000328 |
|
||||
|
||||
`cardsubtypeid -> rec+0x50` is the only family selector; `FUN_1800d8330` maps it to
|
||||
`rec+0x4c cardtype`. Manager merge = `FUN_1801356c0` (queries `managercards` by
|
||||
`carddbid` raw); coach merges live in the respective tables (`fut_coaches.py`).
|
||||
All five render live with real photos/bonuses, zero "DB Error" (CARD_SYSTEM.md,
|
||||
2026-08-05).
|
||||
|
||||
## Players (context, out of taxonomy scope here)
|
||||
|
||||
Players use cardsubtypeid 0..3 -> cardtype 1; merged by `playerid = resourceId &
|
||||
0xffffff` against the local `players` table. Identity/name/nation/team come from
|
||||
the client DB; rating/position/attributes come from our item JSON. See
|
||||
`CARD_SYSTEM.md` and the migration work in `openfut-adapter-fifa17`.
|
||||
|
||||
## Superseded taxonomy (report only — no other files edited)
|
||||
|
||||
The earlier working taxonomy (in prior agent-session notes / long-term memory, and
|
||||
partially in the original `TablesTaxonomy` scout output) got four things wrong.
|
||||
Corrected here:
|
||||
|
||||
1. **Chemistry / play styles are 250-273, NOT 91-136.** 91-110 = `position_mod`,
|
||||
121-136 = `formation_mod`. (OBSERVED in `fcc_trainingcards` + `BY_SUBTYPE`.)
|
||||
2. **6300xxx/6400xxx are KITS, not badges.** Badges are 6000xxx. (OBSERVED.)
|
||||
3. **5004xxx misc cards exist** (subtypes 231/232/233/236, ART 43-46). Previously
|
||||
omitted. (OBSERVED, `fcc_misccards`.)
|
||||
4. **8010xxx league logos exist** (+ stickers), ART 40. Previously omitted.
|
||||
(OBSERVED, `fcc_leaguelogos`.)
|
||||
|
||||
**Repo blast-radius of the superseded values: NONE.** A repo-wide grep
|
||||
(`docs/`, `openfut-adapter-fifa17/`, `openfut-core/`, `fifa17-recon/`) for the wrong
|
||||
claims (`91-136` chem styles; `6300/6400xxx` as badges) returns zero hits.
|
||||
`fifa17-recon/docs/CARD_SYSTEM.md` and `plan-2026-08-06-card-subsystem.md` already
|
||||
use the correct `250..273` for chem styles and the correct staff subtypes 4-8;
|
||||
`CARD_SYSTEM.md` is a mechanism log, not a taxonomy, and asserts none of the wrong
|
||||
ranges. The superseded values therefore live only in non-repo agent memory, which
|
||||
should be corrected to point at this document.
|
||||
@@ -0,0 +1,175 @@
|
||||
# openfut-core — Correctness Issues (audit 2026-08-17)
|
||||
|
||||
Read-only audit of `openfut-core` (game-agnostic axum + SQLite/sqlx economy authority).
|
||||
Four reported issue classes confirmed with exact `file:line` evidence, **plus a bonus
|
||||
HIGH-severity SBC duplicate-card economy exploit**. Nothing here is fixed yet —
|
||||
fixing bumps Core off the frozen P1 reference (`fbb54ea`, the current known-good
|
||||
production Core) and one item needs a DB migration + prod backfill, so this needs a
|
||||
**go/no-go** before rebuild+redeploy.
|
||||
|
||||
> **Zero live users right now**, so the HIGH-severity economy exploits are not
|
||||
> currently exploitable — but they are the exact class (coin overspend + card
|
||||
> duplication) that crashed live clients earlier (project memory), so they should be
|
||||
> fixed before any real play.
|
||||
|
||||
## Architecture context (why the bugs cluster)
|
||||
|
||||
Two generations of economy code coexist:
|
||||
- **NEW** `services/economy.rs` + `routes/economy.rs` — **fully atomic + validated**:
|
||||
every compound op acquires a connection, `BEGIN IMMEDIATE`, composes
|
||||
transaction-scoped primitives (`debit`/`credit`/`add_item`/`remove_item`/
|
||||
`consume_entitlement`), commits/rolls back via `finish()`, rejects negative amounts,
|
||||
and has an extensive in-module test suite. **This is the reference fix pattern.**
|
||||
- **OLD** per-feature services (`club`, `pack`, `market`, `sbc`, `checkin`, `upgrades`,
|
||||
`match_service`, `season`) — predate it, still do read/check/write directly against
|
||||
the `&Pool` with each statement on its own connection: no transaction. `economy.rs`'s
|
||||
own module doc explicitly warns these "cannot offer that guarantee".
|
||||
|
||||
Issues 2 and 3 live entirely in the OLD generation; the fix is to route them through
|
||||
`economy.rs`'s proven atomic ops (or give the pool helpers `&mut SqliteConnection`
|
||||
transactional variants). Request flow is `X-OpenFUT-Game` header → active profile →
|
||||
club → service; clients never pass a `club_id`, so cross-club access is **not** a
|
||||
vector — the risks are intra-club concurrency + unvalidated payloads.
|
||||
|
||||
---
|
||||
|
||||
## Issue 1 — `sbc_submissions` missing `club_id` (milestone always 0) · **LOW**
|
||||
|
||||
- **Root cause:** `migrations/0001_initial.sql:96-102` creates `sbc_submissions(id,
|
||||
profile_id, sbc_id, submitted_card_ids, passed, submitted_at)` — no `club_id`, and no
|
||||
later migration adds one. But `src/routes/club.rs:94-99` (`get_milestones`) runs
|
||||
`SELECT COUNT(*) FROM sbc_submissions WHERE club_id = ? AND passed = 1`. SQLite errors
|
||||
`no such column: club_id`; the error is swallowed by `.unwrap_or(0)` → the
|
||||
`sbcs_completed` milestone is **always 0**. Writer `services/sbc.rs:74-83` inserts
|
||||
`profile_id`, not `club_id`.
|
||||
- **Impact:** wrong milestone stat only. No crash, no economy corruption.
|
||||
- **Fix (matches the ticket — needs migration + backfill):** new migration `0019`:
|
||||
`ALTER TABLE sbc_submissions ADD COLUMN club_id TEXT;` then backfill
|
||||
`UPDATE sbc_submissions SET club_id = (SELECT c.id FROM clubs c WHERE c.profile_id =
|
||||
sbc_submissions.profile_id);` and bind `club_id` in `sbc.rs:submit_sbc`'s INSERT
|
||||
(`club_id` is already a param at `sbc.rs:41`).
|
||||
- **Simpler alternative (no migration):** change the `club.rs:95` query to
|
||||
`WHERE profile_id = ?` (column already exists). Ticket asks for the column, so both
|
||||
are recorded.
|
||||
- **Migration required:** YES (for the ticket's fix); NO (for the alternative).
|
||||
|
||||
## Issue 2 — Non-atomic check-then-act economy mutations (TOCTOU) · **HIGH**
|
||||
|
||||
Each does read → check → write across multiple pool round-trips with no
|
||||
`BEGIN IMMEDIATE`, so concurrent requests race → overspend / duplication / double reward:
|
||||
|
||||
| Site | Race |
|
||||
|---|---|
|
||||
| `services/club.rs:88-115` `spend_coins` | SELECT coins → `if balance<amount` → UPDATE; two concurrent spends both pass → **overspend / negative balance**. Shared primitive used by all callers below. |
|
||||
| `services/pack.rs:58-141` `open_pack` | read `pack.opened` → INSERT cards loop → UPDATE opened=1; concurrent double-open → **card duplication** |
|
||||
| `services/pack.rs:144-160` `buy_pack` | `spend_coins` then `grant_pack`, separate ops → crash between = coins gone, no pack |
|
||||
| `services/market.rs:129-186` `buy_listing` | SELECT sold=0 → spend → UPDATE sold=1 → INSERT; concurrent double-buy → **two cards minted** |
|
||||
| `services/market.rs:188+` `sell_card` | SELECT owned → DELETE → add_coins; concurrent double-sell → **double credit** |
|
||||
| `services/sbc.rs:35-107` `submit_sbc` | validate → DELETE cards → INSERT submission → add_coins/grant_pack; same cards to two SBCs → **double reward** |
|
||||
| `services/checkin.rs:60-120` `claim` | SELECT last → same-day check → reward → INSERT; concurrent → **double claim** |
|
||||
| `services/upgrades.rs:64-95` `change_position` | fetch → spend_coins → UPDATE |
|
||||
| `services/match_service.rs:97-235` + `season.rs` reward | many sequential writes; partial failure leaves partial rewards |
|
||||
|
||||
- **Impact:** corrupts economy state (coin overspend + card duplication).
|
||||
- **Fix:** wrap each compound op in one `BEGIN IMMEDIATE`…`finish()` transaction
|
||||
exactly as `services/economy.rs:214-236` already does; route pack/market/sbc/checkin/
|
||||
upgrades through `economy.rs`'s composed atomic ops (or add `&mut SqliteConnection`
|
||||
variants of `spend_coins`/`add_coins`/`grant_pack`/`add_item`).
|
||||
- **Migration required:** NO (code-only).
|
||||
|
||||
## Issue 3 — Missing input validation: SBC duplicate-card exploit · **HIGH**
|
||||
|
||||
- **Root cause:** `services/sbc.rs:53-70` iterates `req.owned_card_ids` with **no dedup
|
||||
and no length bound**. The same `owned_card_id` repeated N times resolves the same card
|
||||
N times (each `fetch_optional` succeeds); `validate_sbc` (`sbc.rs:110-116`) counts it
|
||||
toward `squad_size` and passes; the DELETE loop deletes it once → **a user satisfies
|
||||
any SBC with ONE card duplicated → free rewards**. Entry point `routes/sbc.rs:30-49`
|
||||
forwards `req` unvalidated. Unbounded Vec length is also a DoS.
|
||||
- **Fix:** in `submit_sbc`, reject duplicate ids (collect into a `HashSet`, compare
|
||||
`len`) and bound the list (e.g. ≤ 30) before resolving → `AppError::BadRequest`.
|
||||
- **Already-good validation (no change):** `economy.rs:64-67,84-87` reject negative
|
||||
amounts; `match_service.rs:100-104` clamps goals 0..99; `upgrades.rs` validates
|
||||
boost 1..3 / positions. Minor: `routes/economy.rs post_grant_reward` forwards an
|
||||
unbounded `amount` (trusted host caller; add an upper-bound sanity guard).
|
||||
- **Migration required:** NO.
|
||||
|
||||
## Issue 4 — `season.rs` panics + checkin index panic · **LOW**
|
||||
|
||||
- **Root cause:** `services/season.rs` `.expect()` on `fetch_optional` Options at
|
||||
`:23` (`get_or_create`), `:69` and `:144` (`record_match`) — panic if the `seasons`
|
||||
row is absent when expected. `seasons.profile_id` is PRIMARY KEY
|
||||
(`migrations/0006_seasons_loans_packs.sql:2`), so the concurrent-insert case surfaces
|
||||
as a UNIQUE error via `?` (not the panic), lowering probability — but it still
|
||||
panics-on-invariant, aborting that request (axum → 500 for the request; not a full
|
||||
server crash).
|
||||
- **Secondary:** `services/checkin.rs:~52,~88` index `STREAK_COINS[idx]` with
|
||||
`idx = ((streak-1)%7) as usize`; Rust `%` can be negative → a corrupt/negative
|
||||
persisted `streak_day` yields a negative index → **panic**.
|
||||
- **Fix:** replace each `.expect(...)` with
|
||||
`.ok_or_else(|| AppError::Internal("season row missing".into()))?`; guard the checkin
|
||||
index with `.rem_euclid(7)` (or clamp `streak_day >= 1` on read).
|
||||
- **Migration required:** NO.
|
||||
|
||||
---
|
||||
|
||||
## Fix plan summary
|
||||
|
||||
| Issue | Severity | Migration | Files |
|
||||
|---|---|---|---|
|
||||
| 1 sbc_submissions club_id | LOW | YES (or none via alt) | `migrations/0001` (+new 0019), `routes/club.rs`, `services/sbc.rs` |
|
||||
| 2 non-atomic mutations | HIGH | NO | `services/{club,pack,market,sbc,checkin,upgrades,match_service,season}.rs` → route through `services/economy.rs` |
|
||||
| 3 SBC duplicate-card exploit | HIGH | NO | `services/sbc.rs`, `routes/sbc.rs` |
|
||||
| 4 season/checkin panics | LOW | NO | `services/season.rs`, `services/checkin.rs` |
|
||||
|
||||
**Recommended order:** 3 (smallest, highest-value: kills the free-reward exploit) → 2
|
||||
(the transactional refactor, largest) → 4 (defensive hygiene) → 1 (cosmetic; do with
|
||||
the alt query unless the column is wanted).
|
||||
|
||||
**Deployment note:** all of these change `openfut-core`, which is currently frozen at
|
||||
the P1 reference (`fbb54ea`) in production. Fixing + rebuilding + redeploying prod-core
|
||||
is a deliberate step off that reference — get a go/no-go first. Only Issue 1's
|
||||
column-add needs a migration + prod backfill; 2/3/4 are code-only.
|
||||
|
||||
---
|
||||
|
||||
## Resolution (2026-08-17, on `openfut-core` @ `fbb54ea` + these edits)
|
||||
|
||||
All four issue classes fixed in the canonical superproject submodule
|
||||
`openfut-core`; full test suite green (179 tests) + clippy clean + a new
|
||||
regression test `tests/integration_test.rs::test_sbc_rejects_duplicate_cards`.
|
||||
|
||||
| Issue | Fix | Files |
|
||||
|---|---|---|
|
||||
| 3 SBC dup-card exploit | dedup (`HashSet`) + `MAX_SBC_CARDS`=30 bound in `submit_sbc`, before card resolution → `BadRequest` | `services/sbc.rs` |
|
||||
| 1 sbc_submissions club_id | migration `0019` adds `club_id` + backfills from `clubs`; `submit_sbc` INSERT now binds `club_id` | `migrations/0019_*.sql`, `services/sbc.rs` |
|
||||
| 4 season/checkin panics | `.expect()` → `.ok_or_else(AppError::Internal)?` (3 sites); checkin index `% 7` → `.rem_euclid(7)` (2 sites) | `services/season.rs`, `services/checkin.rs` |
|
||||
| 2 non-atomic mutations | statement-level compare-and-swap (see below) | `services/{club,pack,market,checkin}.rs` |
|
||||
|
||||
### Issue 2 — how it was fixed, and the residual
|
||||
|
||||
Rather than the full transaction refactor (threading `&mut SqliteConnection`
|
||||
through every service), the concurrency-exploitable races were closed with
|
||||
single-statement **compare-and-swap** — the atomic unit SQLite already gives us,
|
||||
no transaction plumbing, minimal blast radius on the working prod economy path:
|
||||
|
||||
- `club::spend_coins` — `UPDATE … SET coins = coins - ? WHERE id = ? AND coins >= ?`
|
||||
+ `rows_affected` guard; also rejects negative amounts. Kills **overspend** for
|
||||
every caller (the shared root primitive).
|
||||
- `pack::open_pack` — claims the pack (`UPDATE … opened = 1 WHERE … AND opened = 0`)
|
||||
**before** minting cards; loser aborts. Kills **card duplication** via double-open.
|
||||
- `market::buy_listing` — claims the listing (`sold 0→1`) before charging; releases
|
||||
the claim if the debit fails. Kills **double-mint**.
|
||||
- `market::sell_card` — `DELETE … WHERE id = ? AND club_id = ?` + `rows_affected`
|
||||
guard before crediting. Kills **double-credit** via double-sell.
|
||||
- `checkin::claim` — conditional `INSERT … SELECT … WHERE NOT EXISTS (today's row)`
|
||||
+ `rows_affected` guard; pays out only if the claim landed. Kills **double-claim**.
|
||||
|
||||
**Residual (accepted, documented):** the *multi-statement all-or-nothing* edges that
|
||||
need a real transaction to close — `pack::buy_pack` (spend then grant: a crash between
|
||||
loses coins with no pack), `sbc::submit_sbc` (concurrent submits sharing cards could
|
||||
double-consume mid-loop), and `match_service`/`season` reward chains (partial writes on
|
||||
crash). These are **partial-failure durability edges, not statement-level races**, and
|
||||
require concurrency that a single-player FIFA17 client does not generate. Closing them
|
||||
is the `&mut SqliteConnection` transaction refactor originally proposed; deferred as
|
||||
low-value for single-player. Overspend + duplication + double-credit — the vectors that
|
||||
corrupt economy state — are all closed.
|
||||
@@ -0,0 +1,79 @@
|
||||
# FIFA17 Content Completeness (33-record gap)
|
||||
|
||||
Evidence: ContentGapMap scout vs import-input.json (persona 33068179, 1995 items),
|
||||
fifa17-recon/data/tables, manifest/fifa17-import-manifest.json (OBSERVED).
|
||||
|
||||
## Summary
|
||||
| Category | Expected | Resolvable | Unrecoverable |
|
||||
|---|---|---|---|
|
||||
| Legend players | 13 instances (10 defs) | 0 | 13 (need .105 Legends locale names) |
|
||||
| Consumables | 17 | 17 | 0 |
|
||||
| Staff | 3 | 3 | 0 |
|
||||
|
||||
## Consumables (17/17 RESOLVABLE — carddbid present in fcc tables; semantics from cardsubtypeid)
|
||||
| wire_id | resourceId | subtype | kind | table |
|
||||
|---|---|---|---|---|
|
||||
| 100000239 | 5003012 | 54 | gk_training | fcc_trainingcards |
|
||||
| 100000249 | 5003011 | 54 | gk_training | fcc_trainingcards |
|
||||
| 100000260 | 5003004 | 52 | gk_training | fcc_trainingcards |
|
||||
| 100000272 | 5003059 | 91 | position/playstyle mod | fcc_trainingcards |
|
||||
| 100000250 | 5003060 | 92 | position/playstyle mod | fcc_trainingcards |
|
||||
| 100000327 | 5003065 | 97 | position/playstyle mod | fcc_trainingcards |
|
||||
| 100000238 | 5003066 | 98 | position/playstyle mod | fcc_trainingcards |
|
||||
| 100000261 | 5003068 | 100 | position/playstyle mod | fcc_trainingcards |
|
||||
| 100000316 | 5003068 | 100 | position/playstyle mod | fcc_trainingcards |
|
||||
| 100000293 | 5003103 | 258 | player_playstyle | fcc_trainingcards |
|
||||
| 100000326 | 5003112 | 267 | player_playstyle | fcc_trainingcards |
|
||||
| 100000283 | 5003116 | 271 | gk_playstyle | fcc_trainingcards |
|
||||
| 100000294 | 5001004 | 201 | player_contract | fcc_contractcards |
|
||||
| 100000304 | 5001008 | 202 | manager_contract | fcc_contractcards |
|
||||
| 100000305 | 5001009 | 202 | manager_contract | fcc_contractcards |
|
||||
| 100000315 | 5002027 | 217 | healing | fcc_healingcards |
|
||||
| 100000426 | 5002013 | 213 | healing | fcc_healingcards |
|
||||
|
||||
## Staff (3/3 RESOLVABLE)
|
||||
| wire_id | resourceId | subtype | role | table |
|
||||
|---|---|---|---|---|
|
||||
| 100000271 | 3000083 | 8 | fitnesscoach | fitnesscoachcards |
|
||||
| 100000427 | 3000083 | 8 | fitnesscoach | fitnesscoachcards |
|
||||
| 100000282 | 9000081 | 6 | gkcoach | gkcoachcards |
|
||||
|
||||
## Legends (13 instances / 10 defs — UNRECOVERABLE from .120)
|
||||
All defer via NoName gate (openfut-import-fifa17/src/lib.rs:476-478). 6 assets absent
|
||||
from players.json; 236250/236253/236257 resolve only to placeholder nameid 24313='171918'.
|
||||
dcplayernames.json/editedplayernames.json EMPTY in .120 dump. Version formula holds for all
|
||||
(resourceId == (version<<24)|assetId) — so they auto-promote the instant a .105-derived
|
||||
name row exists; NO code change needed, only name data.
|
||||
|
||||
| asset | resourceId(ver) | rareflag/rating/pos | wire copies |
|
||||
|---|---|---|---|
|
||||
| 169193 | 169193(v0) | 1/r87/CDM | 100000057,100000083,100000146,100000157 |
|
||||
| 211029 | 211029(v0) | 1/r73/CB | 100000165 |
|
||||
| 224512 | 224512(v0) | 1/r67/LB | 100000170 |
|
||||
| 227403 | 227403(v0) | 1/r64/ST | 100000160 |
|
||||
| 236743 | 236743(v0) | 1/r60/LB | 100000163 |
|
||||
| 237510 | 237510(v0) | 1/r59/CB | 100000162 |
|
||||
| 236250 | 17013466(v1) | 3/r78/ST | 100001102 |
|
||||
| 236253 | 17013469(v1) | 3/r77/CAM | 100001932 |
|
||||
| 236253 | 100899549(v6) | 21/r77/CAM | 100001472 |
|
||||
| 236257 | 117676769(v7) | 22/r78/LM | 100001043 |
|
||||
|
||||
### .105 read-only verdict (PROVEN 2026-08-14) — UNRECOVERABLE, confirmed
|
||||
SSH read-only to the FIFA machine (10.10.0.105). Install `/mnt/games/FIFA 17`
|
||||
is retail PC (FIFA17.exe, build 2017-06-09, changelist 3175939, sku FFA17PCC).
|
||||
The resident FIFA17.exe database (recon `data/tables`) shows the DLC/Legend name
|
||||
tables ship ZERO rows in the retail PC build:
|
||||
- `dcplayernames.json`: rowcount:0, rows_emitted:0 (nameid range 30000-35000 = DLC names)
|
||||
- `editedplayernames.json`: rowcount:0, rows_emitted:0
|
||||
- `playernames.json`: 24314 rows (standard players only)
|
||||
FUT Legends were Xbox-One-exclusive in FIFA 17; the PC client contains no Legend
|
||||
names at all. The 13 Legend instances are therefore GENUINELY UNRECOVERABLE from
|
||||
any PC install (.105 == .120, same retail build) — NOT a filtering artifact and
|
||||
NOT fabricated. Architecture is already correct: they auto-promote the instant a
|
||||
name row is supplied (e.g. from an Xbox FIFA17 DB), with no importer code change.
|
||||
## Implementation
|
||||
- Consumables+staff: NEW emit path in openfut-import-fifa17 (classify already buckets;
|
||||
plan_definitions only ingests PlayerCard). Add definition builders + Core CardDefinition
|
||||
rows to fifa17-production-cards.json + owned instances in apply.rs. Data 100% present.
|
||||
- Legends: attempt read-only .105 Legends locale research; if names recoverable add roster
|
||||
rows (auto-promote). Otherwise document as unrecoverable (do NOT fabricate names).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,198 @@
|
||||
# FIFA 17 Transfer Market — wire findings
|
||||
|
||||
Reverse-engineering record for the FIFA 17 UTAS transfer-market surface, kept so
|
||||
future agents do not reopen settled questions or re-guess enum spellings.
|
||||
|
||||
Every claim carries a confidence tag:
|
||||
|
||||
| Tag | Meaning |
|
||||
|---|---|
|
||||
| **CONFIRMED** | Observed from our own FIFA17.exe client or live host capture |
|
||||
| **FIFA17-HISTORICAL** | Supported by contemporaneous FIFA 17 implementations (`lorenzh/fut-api`, `futapi/fut` v0.2.18 — the last pre-FIFA-18 release) |
|
||||
| **INFERRED** | Best explanation, not directly captured |
|
||||
| **UNKNOWN** | Requires instrumentation; do NOT implement from guesswork |
|
||||
|
||||
Authority reminder: FIFA 17 field names, enum spellings, sentinel ids and
|
||||
empty-state shapes come from captures or the Python oracle — never from a modern
|
||||
FUT toolkit. Later-FIFA API drift is a known hazard, and reversing a container
|
||||
type or inventing an enum is the documented client-freeze class.
|
||||
|
||||
---
|
||||
|
||||
## The auction record (`auctionInfo[]`)
|
||||
|
||||
What we emit today, on `/tradePile`, `/trade/status` and market browse:
|
||||
|
||||
```json
|
||||
{
|
||||
"tradeId": 1000000097,
|
||||
"itemData": { "...full shaped card...": "", "itemState": "listFS" },
|
||||
"tradeState": "active",
|
||||
"buyNowPrice": 15000,
|
||||
"startingBid": 150,
|
||||
"currentBid": 0,
|
||||
"offers": 0,
|
||||
"bidState": "none",
|
||||
"expires": 3600,
|
||||
"tradeOwner": true,
|
||||
"sellerId": 33068179,
|
||||
"sellerName": "CAGE",
|
||||
"sellerEstablished": 1,
|
||||
"watched": false,
|
||||
"coinsProcessed": 0
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Confidence | Note |
|
||||
|---|---|---|
|
||||
| `tradeOwner` (bool) | **FIFA17-HISTORICAL** | Exists in FIFA 17 auctionInfo. That it is *the* Actions-panel gate is **UNKNOWN** pending live confirmation. |
|
||||
| `sellerId` | **FIFA17-HISTORICAL** exists; type numeric is **INFERRED** | Set to the configured persona so it agrees with `tradeOwner`. Never baked in. |
|
||||
| `sellerName` | **CONFIRMED** it must be the player | `fut_account.py` annotates the persona property as "Blaze PDTL.DSNM / LSX GetProfileResponse Persona / **UTAS sellerName**". EA's `"EASFC"` here is wrong for an own listing. |
|
||||
| `offers` | **FIFA17-HISTORICAL** | `0` valid for active/unbid. |
|
||||
| `bidState: "none"` | **FIFA17-HISTORICAL** | Valid for active/unbid. Other observed concepts: `highest`, `buyNow`. Do NOT "fix" this. |
|
||||
| `expires` | **FIFA17-HISTORICAL** | **SECONDS REMAINING, not an epoch.** Historical durations: 3600, 10800, 21600, 43200, 86400, 259200. |
|
||||
| `itemData.itemState: "listFS"` | **UNKNOWN** | Plausible and unchanged. Public FIFA 17 material gives no trustworthy enumeration. Do not guess replacements — capture. |
|
||||
| `itemData.untradeable` | **FIFA17-HISTORICAL** field; our blanket `false` is **INFERRED** | See "Known debt" below. |
|
||||
| `marketDataMinPrice` / `marketDataMaxPrice` | **do NOT add** | These entered the public parser only after its FIFA 18 migration. |
|
||||
|
||||
### Why the differential could not catch the missing fields
|
||||
|
||||
Our record's key set was **identical to the Python oracle's**, so field-for-field
|
||||
parity was green. The oracle omits `tradeOwner` / `sellerId` / `offers` as well,
|
||||
because *its* remove flow was never driven by a real client either — the only
|
||||
historical live datapoint is a counts-tile bug. Oracle parity is therefore
|
||||
**necessary but not sufficient** for any flow the oracle never actually served.
|
||||
|
||||
The differential now asserts we cover every oracle key AND that our extra keys are
|
||||
exactly `{offers, sellerId, tradeOwner}`, so the deliberate superset is pinned
|
||||
while a new unexplained divergence still fails.
|
||||
|
||||
---
|
||||
|
||||
## Routes
|
||||
|
||||
| Route | Confidence | Behaviour |
|
||||
|---|---|---|
|
||||
| `GET …/trade/status` | **CONFIRMED** the client polls it continuously | Live auction-state refresh. It previously fell through `starts_with("trade")` into the buy/view arm, where the tail has no numeric id, so **every poll returned `{"auctionInfo": []}`**. Now a real handler: optional `tradeIds` filter, else the whole active pile. Unknown ids are absent, never an error. |
|
||||
| `DELETE /ut/game/<sku>/trade/<id>` | **FIFA17-HISTORICAL** | The spelling contemporaneous FIFA 17 clients use, no body, no meaningful response body. Previously landed in the buy/view arm and **silently cancelled nothing while returning 200.** Now maps to MarketCancel. |
|
||||
| `DELETE /ut/delete/game/<sku>/trade/<id>` | **CONFIRMED** (oracle) | The oracle's spelling; retained because the differential exercises it. Whether FIFA17.exe ever uses it is **UNKNOWN**. |
|
||||
| `POST …/auctionhouse` | **CONFIRMED** | List for sale. The client sends only `itemData.id`; the server resolves wire id → Core instance → `card_id`/`resourceId` and enforces ownership. |
|
||||
| `GET …/tradePile/counts` | **INFERRED** | Five scalar ints (`count`, `maxAuctionsAllowed`, `offered`, `selling`, `sold`); a DISTINCT deserializer from `/tradePile`. Exact FIFA 17 semantics of `count` (active auctions vs whole pile) is **UNKNOWN** — we report active auctions and deliberately did NOT speculate. |
|
||||
| `PUT …/item` (move) | **FIFA17-HISTORICAL** | `{"itemData":[{"pile":"trade"|"club","id":ID}]}` → `{"itemData":[{id,pile,success}]}`. Transfer-List membership is a **separate operation from creating an auction**. |
|
||||
|
||||
### Pile encoding
|
||||
|
||||
* MOVE commands take a **string** pile (`"trade"`, `"club"`) — **FIFA17-HISTORICAL**.
|
||||
* Returned `itemData.pile` is documented **numeric** in FIFA 17 auction data — **FIFA17-HISTORICAL**.
|
||||
* The numeric mapping is **UNKNOWN**. Do not unify the two representations, and do
|
||||
not derive a mapping from unrelated `pileSize` keys.
|
||||
|
||||
---
|
||||
|
||||
## Q2 — Transfer List item that is not currently auctioned
|
||||
|
||||
A real FUT state: an item in the Transfer List with no active auction (freshly
|
||||
moved, or expired unsold). **CONFIRMED** to exist as a concept (the external hub
|
||||
spec §27, and move-vs-list being separate operations).
|
||||
|
||||
Its wire representation is **UNKNOWN**: `tradeId` 0 / omitted / null, `tradeState`
|
||||
value or omission, and `itemData.itemState` are all unestablished.
|
||||
|
||||
Consequence, and the reason this matters: our `/tradePile` renders only `active`
|
||||
listings, so keying the `/club` exclusion on the `trade` **pile** stranded 4 cards
|
||||
in no screen at all (hidden from the club, absent from the Transfer List).
|
||||
Commit `f2c4927` keys exclusion on the **active listing** instead, which is
|
||||
self-healing. That is a workaround, not fidelity — the faithful model needs the
|
||||
unlisted state represented.
|
||||
|
||||
**Required capture** (four states, full structural diff, not just a shortlist):
|
||||
|
||||
```
|
||||
A. moved Club -> Transfer List, NEVER listed
|
||||
B. actively listed
|
||||
C. listing expired unsold
|
||||
D. listing sold
|
||||
```
|
||||
|
||||
Diff at least: `tradeId`, `tradeOwner`, `tradeState`, `bidState`, `expires`,
|
||||
`offers`, `currentBid`, `startingBid`, `buyNowPrice`, `sellerId`, `sellerName`,
|
||||
`itemData.id`, `itemData.itemState`, `itemData.pile`, `itemData.untradeable`.
|
||||
|
||||
Do NOT drop the unlisted state from the model just because its encoding is unknown.
|
||||
|
||||
---
|
||||
|
||||
## Deferred, with reasons
|
||||
|
||||
* **5% transfer tax** — **INFERRED** architecture only: auction closes → Core
|
||||
settles → seller credited gross × 0.95, with `auctionInfo` continuing to carry
|
||||
gross. No trustworthy FIFA 17 field named `tax`/`netPrice`/`sellerProceeds` was
|
||||
recovered, and no separate settle operation. Not blocking; do not couple
|
||||
settlement to clearing the sold auction without a capture.
|
||||
* **Bid / Transfer Targets** — not implemented. Watched / active bid / winning /
|
||||
outbid / won / expired are distinct states and must not collapse to a flat list.
|
||||
* **Unassigned** — FIFA 17 had a dedicated Unassigned service; the exact FIFA 17
|
||||
URL is **UNKNOWN**. Our 29-item `purchased` pile is this state and is currently
|
||||
rendered inside `/club`. Do not manufacture a route from a modern toolkit.
|
||||
* **Match CREATE / READY / PLAY** — **UNKNOWN** and explicitly not portable from
|
||||
public FUT web-app work (the web app could not start matches). Instrument the
|
||||
real client from Play Match to kickoff before implementing.
|
||||
|
||||
---
|
||||
|
||||
## Known debt
|
||||
|
||||
`shape_item` reports `untradeable: false` for **every** owned instance. Correct
|
||||
today (Core models no untradeable items) and necessary — a hardcoded `true` greyed
|
||||
out both list buttons — but it will misrepresent SBC / promo / loan rewards once
|
||||
those exist. `untradeable` belongs on the owned-item instance as authoritative
|
||||
state, not inferred from definition, resourceId or rarity.
|
||||
|
||||
---
|
||||
|
||||
## MEASURED in the live client — 2026-08-17
|
||||
|
||||
Read out of the running `FIFA17.exe` (pid-resolved, CardsDLL slide proven against
|
||||
the on-disk FNV prologue) with `fifa17-recon/tools/trade_gate_probe.py`, which
|
||||
extends `gate_byte_probe.py` to vtable slot `+0x270` as the transfer-market
|
||||
analysis asked for. Read-only: `/proc/<pid>/mem` `O_RDONLY` + `pread`.
|
||||
|
||||
| Gate | Python era (2026-08-06) | Now | Owner |
|
||||
|---|---|---|---|
|
||||
| `IS_TRADING_ENABLED` `model+0x1fd2e` (slot `+0x270`) | **0** | **1** | settings struct `+0x28`; was zeroed by `userInfo.feature.trade` |
|
||||
| `TRADE_PILE_SIZE` `model+0x1fd1c` | **0** | **100** | `userMassInfo.pileSizeClientData` key 2 |
|
||||
| watch-list size `model+0x1fd20` | **0** | **50** | same member, key 4 |
|
||||
| `storeEnabled` `model+0x1fd2f` | 1 | 1 | control |
|
||||
| `IS_FRIENDLY_SEASON` / `IS_DRAFT_MODE` / `packOpeningAnimation` | 1 | 1 | controls |
|
||||
|
||||
**CONFIRMED: every CardsDLL-supplied input the transfer-market analysis named as a
|
||||
blocker is now open.** The Rust host does this by construction — it emits
|
||||
`userInfo.feature` as `{}` (no `trade` member, so the kill switch at `0x180174f19`
|
||||
never arms: it fires only when atom `0x330` inside `0x11c` parses as exactly 1) and
|
||||
it already sends `pileSizeClientData` keys 2 and 4. Serving `tradingEnabled: 1` in
|
||||
the settings `configs` array would NOT have worked, because that tail runs after
|
||||
every member is parsed and would overwrite it.
|
||||
|
||||
### What this rules out
|
||||
|
||||
The Transfer List Actions panel not opening on an own listing is therefore **not**:
|
||||
|
||||
* an ownership field — FIFA 17's auctionInfo has no `tradeOwner`/`sellerId` atom;
|
||||
* `IS_TRADING_ENABLED`, `TRADE_PILE_SIZE` or the watch-list size — all measured open;
|
||||
* the cancel route — `DELETE ut/delete/{ns}/trade/{tradeId}` is the PE's spelling and
|
||||
is what we serve;
|
||||
* `tradeState` / `bidState` / `expires` spellings — all three are the PE's own
|
||||
vocabularies and values.
|
||||
|
||||
Per the analysis's own falsifier ("if the byte reads 1 and the screen still refuses,
|
||||
the exe-side predicate has a term we have not enumerated"), the remaining term is
|
||||
**exe-side UI script**, which CardsDLL does not own and the server cannot set.
|
||||
Status: **UNKNOWN**, and it is now the narrowest it has ever been.
|
||||
|
||||
### Confirmed fidelity bug found on the way
|
||||
|
||||
`expires` was a frozen `3600` on every poll, so the client's live countdown never
|
||||
moved and an auction could never run out. Now derived from `created_at + duration`
|
||||
(duration taken from the `ISStart` body), clamped at 0, with an aged-out active
|
||||
listing projecting as `expired`/`none` — FIFA 17's relistable state. Verified live:
|
||||
the standing listing correctly reads `expires: 0` once past its hour.
|
||||
@@ -0,0 +1,208 @@
|
||||
# FIFA 17 FUT Match Lifecycle
|
||||
|
||||
Design + contract reference for the **FUT match loop** as OpenFUT implements it on
|
||||
the backend/responder side. Consolidates knowledge previously scattered across
|
||||
`docs/PROJECT_STATE.md`, `fifa17-recon/tools/utas_server.py` (`match_route`),
|
||||
`openfut-utas-host` (Rust economy END leg), `fifa17-recon/tools/test_match_lifecycle.py`,
|
||||
and the vault (`Protocol Findings.md`, `Project State.md`, `Known Issues.md`).
|
||||
|
||||
Evidence labels: **OBSERVED** (live), **PROVEN** (test/static-analysis),
|
||||
**HYPOTHESIS** (reasoned, not yet live-confirmed).
|
||||
|
||||
> ## Status caveat (read first)
|
||||
> **No football match has ever started or completed in FIFA against this stack.**
|
||||
> The lifecycle below is validated by CardsDLL static analysis (RPC descriptor
|
||||
> blocks) + isolated persistence replay (`test_match_lifecycle.py`), **not** in-game
|
||||
> acceptance. The reward amounts are FUT-plausible env-tunable defaults, **not**
|
||||
> reversed values. Two blockers keep `FUT_MODES` **off by default** (see
|
||||
> [Open questions](#open-questions--blockers)).
|
||||
|
||||
## Scope
|
||||
|
||||
This documents the **UTAS responder handshake + economy reward** for a match — the
|
||||
HTTP calls CardsDLL makes around a match and the state they mutate. It does **not**
|
||||
cover the actual football simulation (Blaze game-server side, "past the FUT hub"),
|
||||
which remains unverified.
|
||||
|
||||
## The loop: a four-call state machine
|
||||
|
||||
CardsDLL issues six match RPCs; four form the playable loop. All share the base
|
||||
path `ut/<sku>/match`; the operation is discriminated by **suffix + body**, not by
|
||||
HTTP verb (verb selection lives outside CardsDLL, so the classifier is verb-agnostic).
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Created: POST /match (no matchId)
|
||||
Created --> Ready: POST|PUT /match/ready {matchId}
|
||||
Ready --> Playing: POST /match {matchId} (bare path + int matchId)
|
||||
Playing --> Ended: POST|PUT|DELETE /match/end {matchId, endReason, ...}
|
||||
Ended --> [*]: rewards credited, W/D/L + matchesPlayed persisted
|
||||
```
|
||||
|
||||
### Call classifier (`_match_call`, utas_server.py:3325)
|
||||
|
||||
The discriminator (**PROVEN** from CardsDLL RPC descriptors):
|
||||
|
||||
1. path ends `/match/end` → **END** (routed as `FutDestroyMatch` regardless of verb).
|
||||
2. body has integer `matchId` on the **bare** `/match` path → **PLAY** (`FutPlayGame`).
|
||||
CREATE and PLAY share `ut/<sku>/match`; the presence of an int `matchId` is the
|
||||
only discriminator — this is why a bare `/match` carrying `matchId` must NOT
|
||||
allocate a new match.
|
||||
3. path ends `/match/ready` → **READY** (`FutMatchReady`).
|
||||
4. otherwise → **CREATE** (`FutCreateMatch`).
|
||||
|
||||
## Per-call contracts
|
||||
|
||||
### CREATE — `POST /ut/<sku>/match` (empty/no `matchId`)
|
||||
CardsDLL: `FutCreateMatch` @ `0x180120380`; deserializes `startDateTime`(740,int),
|
||||
`reportIdEnabled`(641,bool). `squad`(717,nested) is a **FREEZE-RISK** and is omitted
|
||||
(SKIP-safe).
|
||||
|
||||
Response (`match_route`, utas_server.py:3399):
|
||||
```json
|
||||
{"startDateTime": <unix_ts:int>, "reportIdEnabled": false, "id": <matchId:int>}
|
||||
```
|
||||
Effect: allocates a match id; **advances `nextItemId` by 1** (PROVEN,
|
||||
`test_match_lifecycle.py:51`).
|
||||
|
||||
### READY — `POST|PUT /ut/<sku>/match/ready` (`{matchId}`)
|
||||
CardsDLL: `FutMatchReady` — no deserializer at all on the request; the server
|
||||
response parser has two scalar members + one nested member.
|
||||
|
||||
Response (`match_ready_body`, utas_server.py:3353):
|
||||
```json
|
||||
{"matchId": <int>, "opponentPersonaId": <int, default 0>}
|
||||
```
|
||||
- `opponentPersonaId` defaults to `0` — a neutral placeholder, **never** the
|
||||
logged-in user's persona.
|
||||
- The parser also has a nested `items` member (the opponent squad). It is **omitted
|
||||
deliberately** until the opponent-squad item contract is recovered from a live
|
||||
capture; unrecognized/absent members are skip-safe. **This omission is one of the
|
||||
two blockers.**
|
||||
|
||||
### PLAY — `POST /ut/<sku>/match` (bare path, `{matchId:int}`)
|
||||
CardsDLL: `FutPlayGame` — no request deserializer.
|
||||
|
||||
Response: `{}` (empty). Effect: **none** — must NOT allocate a match or advance
|
||||
`nextItemId` (PROVEN, `test_match_lifecycle.py:62-63`). This is purely a client
|
||||
keepalive/transition ack.
|
||||
|
||||
### END — `POST|PUT|DELETE /ut/<sku>/match/end` (`{matchId, endReason, myMatchStats, opponentMatchStats}`)
|
||||
CardsDLL: `FutDestroyMatch` @ `0x180121b60` — **the rewards call**. Routed as
|
||||
DestroyMatch regardless of verb (`FUT_MATCH_END`, default ON).
|
||||
|
||||
> **Wire path (Rust vs Python).** The Rust-owned reward is classified by `classify_economy`
|
||||
> on **`POST /ut/delete/game/<sku>/match`** — EA/CardsDLL tunnels DELETE-semantics ops through the
|
||||
> `/ut/delete/game/` prefix (`FutDestroyMatch` = `DELETE ut/%s/match/{id}`). Python's `match_route`
|
||||
> additionally accepts `/ut/game/<sku>/match/end`. Which exact form the retail client emits is
|
||||
> unverified (no match ever played); the Rust economy owns the `/ut/delete/game` form, and a
|
||||
> `/ut/game/.../match/end` would fall to Python. Both credit the same reward shape.
|
||||
|
||||
Request fields that matter:
|
||||
- `endReason` (atom 260) — **STRING enum, the AUTHORITATIVE result signal**. A score
|
||||
comparison is NOT how the client reports the outcome. Nine values, mapped to a
|
||||
win/draw/loss bucket:
|
||||
|
||||
| endReason | bucket |
|
||||
|---|---|
|
||||
| `WIN`, `DNF_WIN` | won |
|
||||
| `DRAW`, `DNF_DRAW`, `NO_CONTEST` | draw |
|
||||
| `LOSS`, `DNF_LOSS`, `DNF`, `QUIT` | loss |
|
||||
| (missing/unknown) | draw (neutral fallback — credits without inventing a win) |
|
||||
|
||||
- `myMatchStats` / `opponentMatchStats` — literal-keyed objects, 15 int fields each,
|
||||
first is `goals`. **Omitted by the client when `endReason` is `DNF`/`QUIT`**, so
|
||||
nothing may require them. Used only as a fallback outcome probe if `endReason` is
|
||||
absent.
|
||||
|
||||
Response (`destroy_match_body` / Rust `build_match_reward_body`) — every field a
|
||||
**top-level scalar** (zero freeze risk) except the deliberately nested reward:
|
||||
```json
|
||||
{
|
||||
"allCoins": <post-credit balance:int>,
|
||||
"matchCoins": <per-result coins:int>,
|
||||
"seasonCoins": 0,
|
||||
"tournamentCoins": 0,
|
||||
"boostConis": 0, // EA's typo — exact key required
|
||||
"participationAward": <int>,
|
||||
"teamOfTournamentWinner": false,
|
||||
"gameModeAward": { "coins": <total award:int> }
|
||||
}
|
||||
```
|
||||
> **Critical correction (2026-08-04):** the reward `coins` (atom 149) is read by the
|
||||
> deserializer **only inside `gameModeAward`**, never as a top-level key. An earlier
|
||||
> top-level `"coins"` was silently skipped and never reached the client — the one
|
||||
> field most obviously named "the reward" was the one going nowhere.
|
||||
|
||||
Effect (persisted to the active FUT save): credit coins; increment the matching
|
||||
`record.{won,draw,loss}`; increment `matchesPlayed` (PROVEN,
|
||||
`test_match_lifecycle.py:74-81`).
|
||||
|
||||
## Reward policy
|
||||
|
||||
Env-tunable defaults (FUT-plausible, **not reversed** — `economy_policy.rs:20-36`,
|
||||
`utas_server.py:3201-3206`):
|
||||
|
||||
| Result | Match coins | Participation | Total |
|
||||
|---|---|---|---|
|
||||
| Win | 400 (`FUT_MATCH_COINS_WIN`) | 0 (`FUT_MATCH_PARTICIPATION`) | 400 |
|
||||
| Draw | 200 (`FUT_MATCH_COINS_DRAW`) | 0 | 200 |
|
||||
| Loss | 100 (`FUT_MATCH_COINS_LOSS`) | 0 | 100 |
|
||||
|
||||
`allCoins` = post-credit balance; `gameModeAward.coins` = per-result + participation.
|
||||
|
||||
## Ownership split (Rust vs Python)
|
||||
|
||||
The match loop is **partially migrated**. Only the coin-crediting END leg is
|
||||
economy state, so only it is Rust/Core-owned; the CREATE/READY/PLAY legs are still
|
||||
served by the Python oracle.
|
||||
|
||||
| Call | Owner | Where |
|
||||
|---|---|---|
|
||||
| CREATE | Python | `utas_server.py::match_route` (via host `Route::Passthrough`) |
|
||||
| READY | Python | `utas_server.py::match_route` |
|
||||
| PLAY | Python | `utas_server.py::match_route` |
|
||||
| END (reward) | **Rust/Core** (on `POST /ut/delete/game/<sku>/match`) | `EconomyRoute::MatchEnd` → `handle_match_end` → `build_match_reward_body`; outcome via `economy_policy::match_result_from_reason`, coins via `match_result_coins`/`match_reward_total`. 503 on Core error, never Python. Python also accepts `/ut/game/<sku>/match/end`. |
|
||||
|
||||
END is classified in `classify_economy` (`openfut-utas-host/src/lib.rs`) and dispatched
|
||||
by the economy authority barrier ahead of the general classifier — so it can never
|
||||
also reach the Python passthrough (NEVER-BOTH). The Rust END writes the coin reward
|
||||
through the single Core economy transaction (`grant_reward`); W/D/L record + match
|
||||
count still live in the Python save until CREATE/READY/PLAY migrate.
|
||||
|
||||
## Test coverage
|
||||
|
||||
`fifa17-recon/tools/test_match_lifecycle.py` (PROVEN, isolated — temp profile, no
|
||||
live server): drives CREATE→READY→PLAY→END and asserts:
|
||||
- CREATE returns `reportIdEnabled:false` + advances `nextItemId` by 1.
|
||||
- READY returns exactly `{matchId, opponentPersonaId:0}`.
|
||||
- PLAY returns `{}` and does **not** advance `nextItemId`.
|
||||
- END credits `MATCH_COINS["won"] + MATCH_PARTICIPATION`, persists
|
||||
`record == {won:1,draw:0,loss:0}` and `matchesPlayed == 1`.
|
||||
|
||||
## Open questions / blockers
|
||||
|
||||
1. **READY `items` contract (opponent squad)** — the nested `items` member of
|
||||
`FutMatchReadyServerResponse` is unserved pending a live capture. Whether the
|
||||
client requires it present/non-empty to enter a match is unknown. **Blocker.**
|
||||
2. **Client mode-entry gate** — reaching a match from the FUT hub UI is unverified;
|
||||
`FUT_MODES` stays **off by default** (the season/tournament routes return `{}`).
|
||||
3. **No in-game acceptance** — every claim here is static-analysis + isolated replay.
|
||||
The first live match is expected to reveal whether the static read was complete
|
||||
(every match body is logged for exactly this reason).
|
||||
4. **Football simulation** — the actual gameplay (Blaze game-server) is out of scope
|
||||
here and unverified.
|
||||
|
||||
## Sources
|
||||
|
||||
- `docs/PROJECT_STATE.md` (match lifecycle status), `docs/direction.md` (Tier-2 loop).
|
||||
- `fifa17-recon/tools/utas_server.py`: `_match_call` (3325), `match_ready_body` (3346),
|
||||
`match_route` (3357), `destroy_match_body` (3281), `_match_result` (3236),
|
||||
`MATCH_COINS`/`MATCH_PARTICIPATION` (3201), `_END_REASON` (3229); CardsDLL RPC
|
||||
descriptor block (3183-3197).
|
||||
- `openfut-adapter-fifa17/src/fut/economy_policy.rs` (reward policy + outcome map).
|
||||
- `openfut-utas-host/src/lib.rs`: `EconomyRoute::MatchEnd`, `handle_match_end`,
|
||||
`build_match_reward_body`.
|
||||
- `fifa17-recon/tools/test_match_lifecycle.py` (lifecycle regression).
|
||||
- Vault: `02 Reverse Engineering/FIFA 17/Protocol Findings.md`,
|
||||
`06 Agent Memory/{Project State,Known Issues}.md`.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Overnight session handoff — 2026-08-17
|
||||
|
||||
Autonomous session while you slept. Low-ceremony per your instruction. Everything
|
||||
below is verified as noted; nothing was committed or pushed (see
|
||||
[Uncommitted work](#uncommitted-work--needs-your-review)).
|
||||
|
||||
## TL;DR
|
||||
|
||||
1. **Production promotion completed** (you chose "promotion"): prod-host swapped to the
|
||||
post-P1 build, content-gap imported. Then I migrated the **remaining Python UTAS
|
||||
routes that have a known contract** to Rust: account/sync, ut/auth (SID mint),
|
||||
userMassInfo (full), clientdata, club/stats/{country,league,team}, and the trivial
|
||||
static acks. The client's **observed FUT-hub/economy flow is now fully Rust**; a
|
||||
tail of lower-traffic routes **without a captured wire shape** (item-defs,
|
||||
user-identity, watchList/marketdata, non-active `squad/<n>`, draft, and mode-gated
|
||||
season/tournament/champion/leaderboards/sbs) **still proxy to the Python oracle**.
|
||||
2. **Launcher redesigned to a shareholder-grade egui UI** (your headline ask). Builds
|
||||
clean; screenshots captured.
|
||||
3. **New docs:** `MATCH_LIFECYCLE.md` (you asked), `CORE_CORRECTNESS_ISSUES.md`
|
||||
(4 known Core bugs + 1 bonus exploit, ready to fix on your go/no-go).
|
||||
4. **Nothing committed** — all work is in the working tree for your review (git state is
|
||||
delicate: preserved-dirty Core submodule + a concurrent `funman300` actor + detached
|
||||
launcher branch; I didn't want to entangle that unsupervised).
|
||||
|
||||
## What's live in production now (`10.10.0.120:8099`)
|
||||
|
||||
| Thing | State |
|
||||
|---|---|
|
||||
| prod-host binary | post-P1 `fda40d12` **+ my migration rebuild** (release, in `target/release/openfut-utas-host`) |
|
||||
| prod-host pid | 3207781 (hub-managed, restart=no; retained spec points at the rebuilt binary) |
|
||||
| Catalog | `9f6addaa` (post-P1) |
|
||||
| Core content (cards) | `136d8d68` (post-P1, +18 content-gap defs) → Core loads **1710** defs |
|
||||
| Core owned | **1982** (1962 players + 17 consumables + 3 staff) |
|
||||
| Coins | **29,876,776** (baseline — reset from the P1 test value when the content-gap DB was swapped in; you said data isn't precious) |
|
||||
| prod-core | **fixed build** from canonical submodule (`fbb54ea` + 4 correctness fixes), DB migrated ver 18 → 19; binary now `/home/alex/OpenFUT/target/release/openfut-core` |
|
||||
| UTAS routes (Rust) | economy, club, squad (0/active/list/PUT), account/sync, ut/auth, userMassInfo, **user**, clientdata, hub, settings, accountinfo, leaderboards/options, match/reset, phishing, club/stats/{year,consumables,staff,country,league,team}, watchList, static acks (store/keepalive/captcha/tfa/livemessage/activeMessage) |
|
||||
| Still Python (:8199) | item-defs (item/resource, defid), club-identity (clubUser, user/list, user/club), `squad/<n>` (n≠0), draft, marketdata, mode-gated (season/tournament/champion/leaderboards/sbs → `{}` while off), and match CREATE/READY/PLAY. See `docs/PRODUCTION_AUTHORITY_MATRIX.md`. |
|
||||
|
||||
Smoke-verified live in prod (in the prod netns): all migrated routes return
|
||||
`owner=RUST`, coins consistent, clientdata round-trips, club/stats context modes emit
|
||||
distinct nation/league/team buckets. Scripts:
|
||||
`/home/alex/openfut-promotion/economy-2026-08-17-p2/{p2_precheck,smoke_migrated,smoke_clubstats}.py`.
|
||||
|
||||
## Changes made (all verified: builds clean, tests green)
|
||||
|
||||
### 1. Production promotion (deployed)
|
||||
- Host binary `e5be8730` (P1) → `fda40d12` (post-P1) + catalog `9f6addaa`.
|
||||
- Content-gap DB swapped in (owned 1962 → 1982). Backups in
|
||||
`/home/alex/openfut-promotion/economy-2026-08-17-p2/backup/` + `ROLLBACK.txt`.
|
||||
|
||||
### 2. Route migration to Rust (deployed, rebuilt binary)
|
||||
- `POST /ut/auth` — Rust mints the SID (`OPENFUT-SID-{:016X}`), opens the Rust session,
|
||||
adopts persona from body. No Python. (`+ /ut/delete/auth`.)
|
||||
- `POST /openfut/account/sync` — full Rust envelope; coins/unopenedPacks from Core.
|
||||
- `GET /userMassInfo` — **full** Rust envelope (was a Python-proxy+overlay hybrid).
|
||||
- `GET/PUT /clientdata/<key>` — new host `ClientDataStore` (JSON-persisted).
|
||||
- `GET /club/stats/{country,league,team}` — made `club_stats_body` context-aware
|
||||
(nation/league/team buckets); classify now routes all `club/stats/*` to Rust.
|
||||
- `GET /squad/0` — routed to the Rust active-squad projection (verified structurally
|
||||
identical to Python `squad/0`: same 15 keys, players=23).
|
||||
- `GET /watchList` (+ no-op add/remove) — empty list + authoritative Core credits.
|
||||
- Static acks (`store`, `match/keepalive`, `captcha`, `tfa`, `livemessage`,
|
||||
`activeMessage`) — Rust constants (StaticAck route), byte-identical to the oracle.
|
||||
- Captured the remaining routes' Python wire shapes as reference fixtures for later
|
||||
migration: `docs/evidence/route-shapes-2026-08-17/` (user, defs, marketdata,
|
||||
clubUser, watchList, squad/0, season/tournament/champion/sbs, draft).
|
||||
- Files: `openfut-utas-host/src/{lib.rs,clientdata_store.rs(new),config.rs}`,
|
||||
`openfut-adapter-fifa17/src/fut/{non_economy.rs,club_stats.rs}`,
|
||||
`openfut-utas-host/tests/economy_integration.rs`.
|
||||
- Tests: `openfut-utas-host` + `openfut-adapter-fifa17` full suites **GREEN**
|
||||
(188 adapter + 76 host lib + all integration incl the 116s economy integration).
|
||||
|
||||
### 3. Launcher redesign + polish + live account panel (built, NOT deployed — client tool)
|
||||
- **Redesign**: new `openfut-launcher/src/theme.rs` design system (palette, embedded
|
||||
fonts, egui Visuals/Style, card/pill helpers). Branded hero header (OF monogram),
|
||||
left nav rail, card-based dashboard with status pills, prominent accent Launch CTA,
|
||||
console-style Logs. All existing launch/health/preflight/service/config logic preserved.
|
||||
- **Polish**: OpenFUT window/taskbar icon (OF monogram `IconData`), Config tab rebuilt
|
||||
into themed cards, consistency sweep.
|
||||
- **Live "Your Club" panel** (new feature): a background `AccountMonitor` (mirrors
|
||||
`HealthMonitor`, 5s poll, non-blocking) fetches the account summary and the Dashboard
|
||||
shows a "Your Club" card — club name/abbr, Manager, **COINS hero number**, Level + XP
|
||||
bar, unopened packs, account funds — with clean loading/offline/error states.
|
||||
- Builds clean (0 warnings). Screenshots preserved (for your shareholder demo) in
|
||||
`/home/alex/openfut-post-p1/launcher-screenshots-2026-08-17/` — `launcher_account.png`
|
||||
(the populated "Your Club" card: COINS 29,876,776, Level 12, packs 3) is the headline;
|
||||
plus dashboard/setup/logs/config + the offline state. All reviewed — product-quality.
|
||||
- Files: `openfut-launcher/src/{theme.rs(new),account_monitor.rs(new),app.rs,main.rs,
|
||||
account_sync.rs,config.rs}` + `assets/` (fonts + icon). All additive; behavior preserved.
|
||||
|
||||
### 4. Core correctness fixes (deployed 2026-08-17)
|
||||
|
||||
All four `CORE_CORRECTNESS_ISSUES.md` classes fixed in the canonical `openfut-core`
|
||||
submodule and deployed to prod-core (see that doc's "Resolution" section):
|
||||
- **Issue 3 (HIGH, exploit):** SBC duplicate-card free-reward — `submit_sbc` now dedups
|
||||
ids + bounds the list (`MAX_SBC_CARDS`=30) → `BadRequest`. Regression test added.
|
||||
- **Issue 2 (HIGH):** non-atomic economy mutations — closed the concurrency-exploit
|
||||
races with single-statement compare-and-swap (`spend_coins` conditional debit,
|
||||
`open_pack`/`buy_listing`/`sell_card`/`checkin` claim-then-act). Multi-statement
|
||||
partial-failure edges (`buy_pack`, concurrent SBC, match/season chains) left as
|
||||
documented residual — need the transaction refactor, negligible for single-player.
|
||||
- **Issue 4 (LOW):** `season.rs` `.expect()` panics → graceful `AppError`; checkin index
|
||||
`% 7` → `.rem_euclid(7)`.
|
||||
- **Issue 1 (LOW):** `sbc_submissions.club_id` — migration `0019` (add + backfill) +
|
||||
`submit_sbc` binds it; milestone query now correct.
|
||||
- Verified: Core suite **179 green** + clippy clean; migration dry-run on a prod-DB copy;
|
||||
post-deploy prod migration ver 19, owned 1982, coins 29,876,776, all Core + host
|
||||
endpoints 200. Rollback: `backup/prod-core.preCoreFix.db` (ver 18) + old binary path —
|
||||
see `backup/ROLLBACK_CORE_FIX.txt`.
|
||||
|
||||
## New / updated docs
|
||||
|
||||
- `docs/MATCH_LIFECYCLE.md` (NEW) — consolidated FUT match loop design (CREATE→READY→
|
||||
PLAY→END), contracts, reward policy, ownership split, blockers. (You asked for this.)
|
||||
- `docs/CORE_CORRECTNESS_ISSUES.md` (NEW) — 4 known Core bugs + 1 bonus SBC
|
||||
duplicate-card exploit, each with file:line + concrete fix + severity. **Needs your
|
||||
go/no-go** (fixing bumps Core off the frozen P1 reference).
|
||||
- `docs/PRODUCTION_AUTHORITY_MATRIX.md` (UPDATED) — reflects the completed migration.
|
||||
- Vault `06 Agent Memory/Current Priorities.md` (UPDATED).
|
||||
|
||||
## Uncommitted work — needs your review
|
||||
|
||||
**I committed nothing** (git state is delicate: openfut-core is intentionally
|
||||
preserved-dirty; a concurrent `funman300` actor; launcher on detached HEAD `d1a71bd`).
|
||||
Review + commit these when you're ready:
|
||||
|
||||
- Superproject (mine): `openfut-utas-host/src/{lib.rs,config.rs}`,
|
||||
`openfut-utas-host/src/clientdata_store.rs`,
|
||||
`openfut-utas-host/tests/{economy_integration.rs,host_test.rs}`,
|
||||
`openfut-adapter-fifa17/src/fut/{club_stats.rs,non_economy.rs}`,
|
||||
`docs/{PRODUCTION_AUTHORITY_MATRIX.md,MATCH_LIFECYCLE.md,CORE_CORRECTNESS_ISSUES.md,OVERNIGHT_HANDOFF_2026-08-17.md,PYTHON_RETIREMENT_PLAN.md}`,
|
||||
and the reference fixtures `docs/evidence/route-shapes-2026-08-17/`.
|
||||
- Launcher submodule (mine): `src/{app.rs,main.rs,theme.rs(new),account_monitor.rs(new),account_sync.rs,config.rs}`, `assets/` (fonts + icon).
|
||||
- **Leave the pre-existing dirt alone** (not mine): `CLAUDE.md`, `README.md`,
|
||||
`.env.example`, `docker-compose.yml`, `AGENTS.md`, `setup.sh`, `openfut-bridge`,
|
||||
`fifa17-recon/docker/...`, `docs/{ARCHITECTURE,ROADMAP,docker,fifa17-emulation}.md`,
|
||||
`docs/research/`, `scripts/utas-filter-diff.py`.
|
||||
- `openfut-core` (mine, this session): `migrations/0019_sbc_submissions_club_id.sql` (new),
|
||||
`src/services/{sbc.rs,club.rs,pack.rs,market.rs,checkin.rs,season.rs}`,
|
||||
`tests/integration_test.rs`. Built + deployed to prod-core; still detached HEAD at
|
||||
`fbb54ea` (edits uncommitted, per your branch strategy).
|
||||
|
||||
## Open decisions for you
|
||||
|
||||
1. ~~**Core correctness bugs**~~ — **DONE (2026-08-17):** all four classes fixed +
|
||||
deployed to prod-core (see "Core correctness fixes" above and the Resolution section
|
||||
of `docs/CORE_CORRECTNESS_ISSUES.md`). prod-core is now off the frozen P1 point,
|
||||
running `fbb54ea` + fixes at migration ver 19. Residual (documented): the
|
||||
multi-statement transaction refactor for partial-failure atomicity — negligible for
|
||||
single-player; do it if/when concurrency matters.
|
||||
2. **Match handshake legs** (CREATE/READY/PLAY) — the only routes still on Python.
|
||||
Deferred: no match has ever been played in-game, READY `items` contract unknown,
|
||||
`FUT_MODES` off (see `docs/MATCH_LIFECYCLE.md`). Migrating them risks the economy
|
||||
`/match/end` routing for a never-exercised path — I judged it not worth it unmonitored.
|
||||
3. **Aux service container cutover** (blaze/redirector/roster → Rust) — operator-gated
|
||||
container change; unchanged.
|
||||
|
||||
## Rollback (still hot)
|
||||
|
||||
- Python P2 image `openfut-fut-backend:p2-rollback` (b1b929953f) + profile 39bb3e83 +
|
||||
`rollback-to-python-p2.sh`.
|
||||
- prod-host P1 binary + P1 catalog backed up in
|
||||
`/home/alex/openfut-promotion/economy-2026-08-17-p2/backup/` (see `ROLLBACK.txt`).
|
||||
- prod state (pre-content-gap) backed up: `backup/prod-core.preB.db`,
|
||||
`prod-identity.preB.json`, plus P1 `fifa17-production-{catalog,cards}.p1.json`.
|
||||
@@ -0,0 +1,165 @@
|
||||
# Production Authority Matrix (post-P1 -> P2-routes promoted 2026-08-17)
|
||||
|
||||
> **P2-routes promoted to production 2026-08-17.** prod-host swapped P1 `e5be8730` -> post-P1
|
||||
> `fda40d12`; catalog `35a0913b` -> `9f6addaa` (resolves all owned assets, dropped_no_asset=0).
|
||||
> Every previously-Python non-economy route now RUST in prod (OBSERVED prod log). Rollback hot:
|
||||
> restore P1 binary + backup catalog (`economy-2026-08-17-p2/backup/`).
|
||||
|
||||
Definitive inventory of every production-reachable FIFA17 route/service and its
|
||||
current owner. Derived from the live `prod-host` dispatch log (owner= labels,
|
||||
real Client A session 2026-08-14), `openfut-utas-host/ROUTE_AUTHORITY.md`, and the
|
||||
prod container config (`OPENFUT_SERVERS="blaze roster pow"`).
|
||||
|
||||
Evidence labels: OBSERVED (live log/db), PROVEN (test), INFERRED, HYPOTHESIS.
|
||||
|
||||
Legend: owner R = Rust/Core, P = Python oracle (:8199 proxied via PYTHON_FALLBACK).
|
||||
|
||||
## UTAS HTTP (front door: openfut-utas-host :8099)
|
||||
|
||||
### ECONOMY — already Rust (Python economy hits = 0, OBSERVED)
|
||||
| Method/path (tail) | Prod owner | Writes state | Rust handler | Py proxy |
|
||||
|---|---|---|---|---|
|
||||
| GET /user/credits | R | no | handle_credits | NO |
|
||||
| GET /store/purchasegroup[/all] | R | no | handle_purchasegroup | NO |
|
||||
| PUT /store/transaction[/<id>] | R | coins,inv,pile | handle_store_buy | NO |
|
||||
| POST /purchased[/items] | R | coins,inv,ent,pile | handle_pack_open | NO |
|
||||
| GET /purchased[/items] | R | no | shape_purchased_reveal | NO |
|
||||
| DELETE /item/<id> | R | coins,inv | handle_quick_sell_path | NO |
|
||||
| POST /ut/delete/../item | R | coins,inv | handle_quick_sell_body | NO |
|
||||
| PUT /item | R | inv,pile | handle_move_items | NO |
|
||||
| POST /ut/delete/../match | R | coins | handle_match_end | NO |
|
||||
| POST /auctionhouse,/transfermarket | R | listings | handle_market_list | NO |
|
||||
| GET /tradePile | R | no | handle_market_query | NO |
|
||||
| GET /tradePile/counts | R | no | handle_market_counts | NO |
|
||||
| /trade/<id> (POST/PUT/GET) | R | coins,inv,listings | handle_market_buy | NO |
|
||||
| DELETE /ut/delete/../trade/<id> | R | listings | handle_market_cancel | NO |
|
||||
|
||||
**Transfer-market semantics (live-verified 2026-08-17).** Three things here are
|
||||
load-bearing and were each a live defect:
|
||||
1. `/tradePile` and `/tradePile/counts` are **different deserializers** and MUST NOT
|
||||
share a handler. `/counts` is FutGetAuctionCount: five scalar ints
|
||||
(`count`, `maxAuctionsAllowed`, `offered`, `selling`, `sold`) and nothing else.
|
||||
Served the `auctionInfo` body it skips every field, leaving the counts at 0 — the
|
||||
hub tile shows a listing while the Transfer List screen shows no active sale.
|
||||
2. An auction record's `itemData` MUST be the **full card object** (the same shape
|
||||
`/club` emits), not a stub. A listing therefore persists a shaped-card SNAPSHOT
|
||||
(`listings.item_json`) at list time. The seller's own pile stamps
|
||||
`itemState: listFS`; market search uses `forSale`.
|
||||
3. `POST /auctionhouse` resolves the listed card **server-side** from the wire item
|
||||
id (`wire → Core instance → card_id + resourceId`); the client's FutISStart body
|
||||
carries only the id. This also enforces that you can only list what you own.
|
||||
|
||||
### NON-ECONOMY — Rust-owned (route migration 2026-08-17, OBSERVED prod log)
|
||||
| Route | Owner | Notes |
|
||||
|---|---|---|
|
||||
| POST /ut/auth (+ /ut/delete/auth) | R | Rust mints the SID (OPENFUT-SID-{:016X}); opens Rust session; adopts persona from body. No Python. |
|
||||
| POST /openfut/account/sync | R | full Rust envelope; coins/unopenedPacks from Core; clubName OpenFUT / clubAbbr OFC constants |
|
||||
| GET /userMassInfo | R | FULL Rust envelope (userInfo+squad+settings+pileSizeClientData); no Python. coins from Core, squad == /squad/active |
|
||||
| GET/PUT /clientdata/<key> | R | host ClientDataStore (JSON-persisted); PUT acks {}, GET returns blob or {} |
|
||||
| capability (/openfut/fifa17/capability) | R | -> Bound (CleanV1) |
|
||||
| GET /club, /club/* readers | R | Core-backed collection (dropped_no_asset=0). Cards with an **ACTIVE listing are excluded** — a listed card has left the club. Keyed on the listing, NOT on the `trade` pile: the pile can hold cards with no listing (bare move, or cancelled/sold) and `/tradePile` renders only ACTIVE listings, so hiding the pile would make those invisible in BOTH views. Keying on the listing is self-healing — cancel/sale restores club visibility with no extra transition. Pagination then runs over the club-visible set (Core cannot filter host-owned listing state, so letting it paginate would yield short pages); with nothing hidden the fast Core-paginated path is unchanged. |
|
||||
| GET /squad/0, /squad/active, /squad/list; PUT /squad/<n> | R | Core squad projection + tx; GET /squad/0 == active squad (verified structurally identical) |
|
||||
| GET /user/accountinfo | R | {} |
|
||||
| GET /user | R | `{"userInfo": …}` — same userInfo builder as userMassInfo (shared); squad rating is Core-authoritative (DIFFERENT-BY-DESIGN vs Python's stale value) |
|
||||
| GET /settings | R | {"configs":[]} |
|
||||
| GET /leaderboards/options | R | {} |
|
||||
| PUT /match/reset | R | {} |
|
||||
| GET /phishing/trusteddevice | R | security-question stateless ack |
|
||||
| GET /hub | R | Core-derived counts; `clubPlayers` excludes actively-listed cards, `auctionCount`/`tradePile` from the durable market store |
|
||||
| GET /club/stats/{year,consumables,staff,country,league,team} | R | Core aggregation; context buckets keyed nation/league/team (owned=1982); staff={} |
|
||||
| GET /store, /match/keepalive, /captcha, /tfa, /livemessage, /activeMessage | R | unconditional constant acks (byte-identical to the oracle; StaticAck route) |
|
||||
| GET /watchList (+ PUT/POST/DELETE) | R | empty watch list + authoritative Core credits; add/remove is a no-op ack (oracle persists none) |
|
||||
| GET /season, /tournament, /champion, /clubUser, /user/list | R | FUT modes + club-identity off → `{}` (FeatureOffEmpty; byte-identical to the flag-off oracle). Migrated + deployed 2026-08-17 |
|
||||
| POST /ut/.../match/end (DestroyMatch) | R | economy reward (coins credited via Core grant_reward) |
|
||||
| GET /item/resource, /defid | R | `{itemData:[item_def…]}` — asset=rid&0xffffff; hardcoded Ronaldo (20801) + placeholder ("Player",75,CM,attrs 70), mirroring the oracle's `item_def`. Client renders from its LOCAL DB, so the placeholder is exact parity. Migrated + deployed 2026-08-17 |
|
||||
| GET /marketdata, /marketdata/pricelimits | R | suggested pricing, constant band 150..15000. `/pricelimits` = bare ARRAY (one per defId); plain `/marketdata` = OBJECT — container type is load-bearing (object-where-array froze a live client). Migrated + deployed 2026-08-17 |
|
||||
|
||||
### NON-ECONOMY — still Python (PYTHON_FALLBACK)
|
||||
| Method/path | Owner | Reason |
|
||||
|---|---|---|
|
||||
| POST /user/club (rename) | P | mutating club rename; Core has `clubs` but rename needs a Core write (deferred). Reads `clubUser`/`user/list` are now Rust. |
|
||||
| GET /squad/<n> (n≠0, non-active) | P | no multi-squad Core model (Rust owns squad/0, squad/active, /squad/list, PUT) |
|
||||
| /squad/mode/draft/* | P | FUT Draft mode |
|
||||
| GET /leaderboards, /sbs/* | P | mode-gated (FUT_MODES/_SBC off → `{}`/content; `/sbs/sets` ships content); real behavior needs the mode logic ported. `season`/`tournament`/`champion` are now Rust. |
|
||||
| POST /ut/.../match (CREATE), /match/ready (READY), /match (PLAY) | P | match handshake legs; no match ever played in-game (see docs/MATCH_LIFECYCLE.md) |
|
||||
|
||||
## AUXILIARY SERVICES (prod container OPENFUT_SERVERS="blaze roster pow")
|
||||
All aux services are **Python in production today** (container `entrypoint.sh` runs
|
||||
`blaze_responder_v3b.py`/`roster_server.py`/`pow_server.py`). Rust equivalents exist
|
||||
outside Docker; deploying them is operator-gated (production deployment forbidden here).
|
||||
|
||||
| Service | Rust crate | Completeness | Prod owner | Reachable | Blocker to candidate |
|
||||
|---|---|---|---|---|---|
|
||||
| Blaze (:42130) | openfut-blaze-host + openfut-protocol-blaze + adapter::blaze | COMPLETE, gate-proven to real FUT (Gate 10: 3 logins, 10 pack opens, 448/448 py suite) | Python | yes | ERRC error-reply placement unresolved; wire into candidate bring-up |
|
||||
| Redirector (:42127 TLS) | openfut-redirector-host + openfut-tls (OpenSSL vendored) | COMPLETE, 1 live handshake 2026-08-11 (TLSv1.2/AES256-GCM-SHA384) | Python | yes | never full-path gated; cert consistency |
|
||||
| Roster (:8081) | openfut-roster-host | COMPLETE, oracle-parity + lifecycle tests, unit-only | Python | yes | never live-gated |
|
||||
| POW (:8094 + :8080) | NONE (only pow_server.py) | no Rust host; 58 templates, bodies placeholder | Python | yes | LARGEST: no crate + bodies un-reversed |
|
||||
| Nucleus (:42131) | none (advertised string only) | n/a | Python stub (advertised, unused) | NO (0 live hits) | DEAD in current flow — keep advertising URL, no migration |
|
||||
|
||||
RETIRED/out-of-scope: fifa-blaze (FIFA23 capture stub), openfut-bridge (FIFA23). Not in FIFA17 flow.
|
||||
|
||||
## NON-ECONOMY UTAS TARGET OWNER (confirmed via scouts)
|
||||
Core already exposes GET/PUT /settings, GET /club|/collection|/statistics|/profile, /squad/*.
|
||||
New Rust arm = Route variant + classify() arm (lib.rs:118-140) + owner-labelled handler.
|
||||
|
||||
| Route | Port complexity | Target | Notes |
|
||||
|---|---|---|---|
|
||||
| user/accountinfo | trivial-static ({}) | R | host constant |
|
||||
| settings | trivial-static ({"configs":[]}) | R | host constant / Core /settings |
|
||||
| leaderboards/options | trivial-static | R | host constant |
|
||||
| match/reset | trivial-static ({}) | R | host constant ack |
|
||||
| phishing/trusteddevice | small (validate hex + constant) | R | security-question: stateless ack, always verified/trusted |
|
||||
| clientdata/userHubData | small stateful | R | Core PUT /settings upsert keyed userHubData |
|
||||
| hub | medium (derived counts) | R | Core collection + market counts |
|
||||
| club/stats/year | medium (rating-tier aggregation) | R | Core /collection + /statistics |
|
||||
| club/stats/consumables | medium | R | BLOCKED on 17-consumable import |
|
||||
| club/stats/staff | trivial ({}) or derived | R | BLOCKED on 3-staff import |
|
||||
| account/sync | small-medium (persona select + save) | R | hardest: no direct Core route; host session/persona logic |
|
||||
| ut/auth envelope | small (SID mint) | R (currently OBSERVE) | Python still mints envelope; boundary decision |
|
||||
|
||||
## MIGRATION PRIORITY ORDER (Phase 12)
|
||||
1. Content gap consumable+staff emit (unblocks club/stats/{consumables,staff}) — import crate.
|
||||
2. Trivial-static host routes: accountinfo, settings, leaderboards/options, match/reset, phishing/trusteddevice.
|
||||
3. clientdata/userHubData (Core settings upsert).
|
||||
4. hub + club/stats/year (Core-derived aggregation).
|
||||
5. userMassInfo full ownership (envelope scaffold; econ+squad already Rust).
|
||||
6. account/sync + ut/auth envelope (account/session boundary).
|
||||
7. Aux candidate wiring (blaze/roster/redirector already built) + POW (blocked) + Nucleus (dead).
|
||||
|
||||
## ECONOMY EXPECTATION
|
||||
Python economy hits = 0 (OBSERVED live + PROVEN by NEVER-BOTH/no-fallback tests).
|
||||
Any nonzero Python economy hit = P1 regression, priority zero.
|
||||
|
||||
## STATUS
|
||||
Baseline + economy + Rust-owned non-economy rows: OBSERVED/PROVEN.
|
||||
Aux + non-economy target owners: confirmed via HostSourceMap/PythonContractMap/AuxServiceMap/ContentGapMap scouts.
|
||||
|
||||
## CANDIDATE MIGRATION STATUS (post-P1 progress, off-production)
|
||||
DONE (Rust-owned in candidate source; committed on top of 5020137; workspace tests green):
|
||||
- Content gap: consumable+staff emit (20 instances / 18 defs; verified real-profile re-import 1962+20).
|
||||
- Non-economy routes migrated to Rust: user/accountinfo, settings, leaderboards/options,
|
||||
match/reset, phishing/{trusteddevice,question,validate}, club/stats/staff, hub,
|
||||
club/stats/{year,consumables}.
|
||||
(hub clubPlayers = owned player count from Core; may be < Python profile count by the deferred
|
||||
Legend instances = DIFFERENT-BY-DESIGN.)
|
||||
- Reachability reporter (scripts/openfut-reachability.py) gates Python-hit invariants.
|
||||
|
||||
RESIDUAL PYTHON (still proxied; each has a concrete blocker, not ordinary difficulty):
|
||||
- club/stats/{country,league,team} — RESIDUAL (nation/league/team context sub-screens). The global
|
||||
MY-CLUB stat set (club/stats/{year,consumables,staff}) is now Rust (adapter club_stats.rs faithful
|
||||
port of fut_club_stats.py VOCAB + counts, Core-accurate over real imported content, staging-verified
|
||||
RUST route=club-stats owned=1982 players=1962). The per-nation-bucket context sub-screens still proxy
|
||||
Python (low value, unrecognized atoms inert); migrate with a live context capture if ever needed.
|
||||
- account/sync — launcher-facing (POST /openfut/account/sync, pre-auth); envelope has fields not in
|
||||
Core (clubAbbr, established, profilePath, accountFunds cap); changing it risks the launcher. Needs
|
||||
a Core account endpoint or host account logic + launcher-contract verification.
|
||||
- userMassInfo — SAFE hybrid today (Rust overlays economy+squad on Python envelope). Full ownership
|
||||
needs a real full-envelope capture first (missing one scaffold field breaks a hot route); deferred.
|
||||
- ut/auth — ORDERING-BLOCKED: still-proxied Python routes rely on Python's session table, so auth
|
||||
must stay proxied (Python mints SID, Rust OBSERVEs) until every session-needing route is Rust. Migrate LAST.
|
||||
- clientdata/userHubData — BLOCKED: Core /settings supports only 2 fixed keys (difficulty,
|
||||
preferred_formation); storing an arbitrary blob needs a frozen-Core change or a new host store.
|
||||
Low-value UI-pref blob; kept Python per Phase 23 (no Python-by-ideology).
|
||||
|
||||
AUX (Rust built, deploy = operator-gated container cutover, NOT this task): blaze/roster/redirector.
|
||||
POW = blocked (no crate, bodies un-reversed). Nucleus = dead (0 live hits).
|
||||
@@ -0,0 +1,192 @@
|
||||
# OpenFUT — Project State
|
||||
|
||||
> **Canonical source:** `../OpenFUT-Vault/06 Agent Memory/Project State.md`
|
||||
> This file is a mirror. If the two disagree, the vault wins. Update the vault first.
|
||||
|
||||
Factual snapshot. Prefer this over the stale root `README.md`/`CLAUDE.md` status tables (FIFA 23).
|
||||
Last compiled from repository evidence during context initialization.
|
||||
|
||||
## Working
|
||||
|
||||
- **FIFA 17 offline FUT stack, end-to-end.** Proven 2026-08-01: auth → Blaze login → device-trust →
|
||||
the FUT hub. Brought up by `fifa17-recon/tools/openfut-fut.sh start`. Evidence: `FUT-RUNBOOK.md`,
|
||||
`fifa17-recon/README.md`, the five responder scripts, gate-ladder troubleshooting table.
|
||||
- **ProtoSSL cert-pin defeat** — two live `/proc/PID/mem` patches (`autopatch.py`), VAs stable
|
||||
across launches. gdb-verified which gate was the wall.
|
||||
- **LSX / Origin layer** — crypto handshake reversed byte-exact and confirmed against the repack's
|
||||
own emu disassembly (`docs/REPACK_INTEL.md`); Origin login gates cleared.
|
||||
- **Blaze redirector + Fire2/Heat2** — both hops defeated; preAuth/login/personas answered.
|
||||
- **UTAS/RS4 FUT API** — `ut/auth` + boot calls + device-trust reach the hub with hand-authored JSON.
|
||||
- **Persistent FIFA 17 account selection** — the launcher synchronizes one configured EA persona to
|
||||
the Python backend before starting LSX/FIFA. LSX, Blaze, POW/EASFC, and UTAS then share that
|
||||
identity, while FUT coins, inventory, squads, progression, and unopened packs persist in an
|
||||
isolated save beneath `fifa17-recon/docker/state/accounts/<persona-id>/`. The POW level/XP/funds
|
||||
shown in FIFA's general account bar are account-scoped but remain distinct from FUT club coins.
|
||||
A reversible server test on 2026-08-09 verified profile switching, POW values, a 400-coin pack
|
||||
debit, five awarded items, and restoration of the original profile.
|
||||
- **Account-scoped FUT security compatibility** — launcher account synchronization initializes a
|
||||
persisted `securityQuestion` verification record in that persona's FIFA 17 profile. The UTAS
|
||||
PHISHING handler returns the complete CardsDLL trusted-console response (`changed`, `exists`,
|
||||
`locked`, `trusted`), accepts only well-formed legacy setup/validate requests under `X-UT-SID`,
|
||||
and never stores or logs the client-transformed answer. This is server-side emulation; the hook
|
||||
and launcher do not contain an answer or add UI automation. Automated contract coverage is in
|
||||
`fifa17-recon/docker/ctx/tools/test_security_question.py`; live first/repeat-launch acceptance is
|
||||
partially complete: the first launch entered FUT without a security dialog on 2026-08-09; a
|
||||
second fresh-process FUT entry is still required to close persistence acceptance.
|
||||
- **Safe responder diagnostics** — ordinary LSX logs redact challenge/session/auth-code attributes;
|
||||
ordinary Blaze logs redact auth/session keys and no longer emit raw Fire2 hex, decoded TDF, or
|
||||
config values. Forensic Blaze capture remains available only with the explicit
|
||||
`OPENFUT_BLAZE_DUMP_FRAMES=1` opt-in. LSX and Blaze self-tests cover the new defaults.
|
||||
- **OpenFUT Core** — Rust FUT economy backend, feature-complete for its scope and tested: profiles,
|
||||
clubs, coins, packs, cards, squads, chemistry styles, SBCs, objectives, matches, market (NPC),
|
||||
draft, FUT Champs, seasons, statistics, achievements, events, daily check-in, division
|
||||
leaderboard, market trade history. 13 migrations. Integration suite (`tests/integration_test.rs`,
|
||||
96 test fns) runs against in-memory SQLite; CI (fmt/clippy/build/test) green on `openfut-core`.
|
||||
|
||||
## Partially implemented
|
||||
|
||||
- **FIFA 17 FUT hub depth** — reaching the hub is proven, but how much of FUT is fully navigable
|
||||
beyond it (playing matches, pack opening, SBC submission through the *game* UI vs. spinner/error
|
||||
states) is not documented as complete. The runbook's gate ladder lists failure modes still
|
||||
guarded against. Treat "past the hub" as unverified.
|
||||
- **Pack opening through the game UI** — proven live on 2026-08-09 with the recovered CAGE test
|
||||
profile: purchase, reveal, item assignment/quick-sell, wallet refresh, and return from the reveal
|
||||
all completed. The Python transaction path also passes its 446-check contract suite. FIFA's
|
||||
hardcoded post-reveal `mypacks` return is supported by a short-lived active grace record for every
|
||||
opened pack; it is excluded from unopened-pack counts and retired at the next hub request.
|
||||
- **FIFA 17 FUT match lifecycle** — CardsDLL static analysis and isolated responder tests now cover
|
||||
CREATE→READY→PLAY→END. Bare `/match` requests carrying body `matchId` are classified as PLAY
|
||||
instead of accidentally allocating another match; READY returns the verified scalar `matchId`
|
||||
and `opponentPersonaId` fields; END persists W/D/L, matches played, and coin rewards per account.
|
||||
The implementation is deployed and `test_match_lifecycle.py` passes, but no football match has
|
||||
started or completed in FIFA yet. The READY opponent `items` contract and client mode-entry gate
|
||||
remain unresolved; `FUT_MODES` therefore stays off by default.
|
||||
- **Core ↔ emulation integration** — the two halves exist and wiring has **started**. First
|
||||
slice (2026-08-11): the My Squad owned-player search. `openfut-core` gained a semantic,
|
||||
game-independent owned-inventory query (`services::inventory::{OwnedItemQuery, apply_query}`
|
||||
+ a `Quality` tier) that filters (AND) → orders deterministically → paginates, wired into
|
||||
`GET /collection`; `openfut-adapter-fifa17::fut::owned_query` parses the FIFA17 `/club` wire
|
||||
query and resolves numeric league/nation/team ids → semantic names (unknown id = hard error,
|
||||
no raw-id passthrough). Intentional fix, not parity: Python applies only `league`+`team` and
|
||||
ignores `level`/`rare`/`position`/`nation`/`start`/`count` (the request-amplification bug);
|
||||
Core applies all proven filters and paginates. `rare=SP` semantics UNKNOWN, unimplemented.
|
||||
Slice 2 (2026-08-11): `openfut-utas-host` — the first live UTAS host. Serves `GET …/club`
|
||||
from Core through the adapter and reverse-proxies every other UTAS route verbatim to the
|
||||
Python oracle (`utas_server.py`); plaintext HTTP/1.1 keep-alive, classify-before-execute,
|
||||
no python-fallback after a Core error. `CoreAccess` is a host-owned boundary (the adapter
|
||||
stays transport-agnostic). 11 host + 22 adapter tests; 10/10 mutations killed; fmt/clippy
|
||||
clean. Slice 3 (2026-08-11, `3ef3bc3`): the real `Fifa17IdentityResolver` — catalog
|
||||
(card id → real asset id) + persistent `openfut-identity` store (owned instance →
|
||||
stable/reversible wire int) + wire-id policy, replacing all placeholders (one
|
||||
production path). Wire-id namespace is globally monotonic within `(fifa17, owned-item)`,
|
||||
not per-account (Core owned ids are UUIDs → unambiguous reverse). Slice 4 (2026-08-11,
|
||||
core `36abd4b`): a curated 32-card real FIFA17 dev content pack
|
||||
(`data/games/fifa17/dev/cards.json`, ids `fifa17_<asset>`), loaded only via opt-in
|
||||
`Config.dev_content_games`; `seed-dev` grants a `game_id=fifa17` profile+club real
|
||||
`OwnedCard`s (no FIFA wire ids — the resolver mints those at request time), idempotent,
|
||||
default content untouched. Slice 5 (2026-08-11, `5276dd2`): the host sends
|
||||
`X-OpenFUT-Game: fifa17` so `/club` resolves the all-mapped fifa17 profile —
|
||||
**composition proven live** over HTTP (real Core+host, no FIFA client): FIFA wire query
|
||||
→ real `resourceId`s (catalog) + stable/reversible wire `id`s (store); 33 renderable,
|
||||
gold=22, Premier League=18, pagination page1=11/page2=7/overlap=0 (clean paging, no
|
||||
drops). **The only remaining gate is the live retail FIFA A/B** (no FIFA client in the
|
||||
build env; runbook `openfut-utas-host/README.md`). See the vault UTAS Endpoint Map +
|
||||
Known Issues (incl. "identity resolution is NOT authorization" for later mutations).
|
||||
**Slice 6 (2026-08-11): `/club` RUNTIME VALIDATED on retail FIFA 17** — operator-assisted
|
||||
live A/B (`.105` client → `.120` backend via a source-scoped NAT redirect into a staged
|
||||
`36abd4b` Core + `openfut-utas-host`). Every checkpoint passed on the real client:
|
||||
transport, per-route Python fallback, Python-negative `/club`, Core `X-OpenFUT-Game`
|
||||
scoping, real card + persistent owned-item identity (incl. the two-copy fixture),
|
||||
no-filter/Gold/position/nation/league/team/combined-AND filtering, retail pagination
|
||||
with no amplification, card selection, identity stability across relaunch, Python
|
||||
rollback, and Rust re-enable (identity store byte-identical across the cycle, 0
|
||||
reallocation). Live findings: the retail client steps `start += 10` with `count=11`
|
||||
(sometimes bulk `count=100`) — Core honours `offset` and terminates; the My Club UI
|
||||
nests team under league; the "~1900" club counter is Python `userMassInfo`, not `/club`.
|
||||
Remaining is operational only (promote the staged stack to a durable deployment).
|
||||
- **Slice 7 (2026-08-12): FUT squad authority (read + write) RUNTIME VALIDATED on retail FIFA 17.**
|
||||
Staged operator-assisted A/B (`.105` retail client → `.120`, source-scoped utas switch
|
||||
`:8099→:8199` into `openfut-utas-host` over a staged `615c5fd` Core seeded by `seed-dev` with the
|
||||
dev 33-card inventory). FIFA itself consumed the Rust squad path end-to-end: FUT boot served
|
||||
`RUST_OVERLAY userMassInfo` (squad overlay) and the squad screen rendered the dev XI; a controlled
|
||||
in-game squad edit issued `PUT …/squad/0` → host `squad-replace` → Core → `{"id":0}`; an in-game
|
||||
formation change f442→f433 persisted to Core (canonical fingerprint changed, `position_index`
|
||||
remapped 0–10); a FULL FIFA relaunch cold-fetched and rendered the persisted f433 squad (no client
|
||||
cache). Reversibility proven on the exact client path by log presence, not response data (both
|
||||
backends coincidentally hold the same dev squad — the Python oracle `fifa17_profile.json` was
|
||||
seeded from the same `squad_put_f442.json` capture): disarmed `.105:8099` reached Python (absent
|
||||
from host log), re-armed reached Rust (`route=club limit=Some(9)` present); the persistent identity
|
||||
store survived the cycle with 0 reallocation. Non-migrated routes (`/ut/auth`, `account/sync`,
|
||||
`accountinfo`, `settings`, `hub`, store txn) correctly Python-fallback. NEW startup requirement:
|
||||
the Core server loads dev card defs only for games in env `OPENFUT_DEV_CONTENT_GAMES` (comma-sep);
|
||||
omitting `fifa17` makes `get_collection` silently drop every owned card (empty `/collection`,
|
||||
"no squad") despite a successful seed — MUST become a deployment/preflight assertion. Preceded the
|
||||
same day by a staged two-process parity gate (real Core+host over HTTP, no FIFA) confirming byte-
|
||||
shape parity of `userMassInfo.squad` vs the captured oracle. Next milestone: real-data Core
|
||||
import / profile strategy → production Rust UTAS. Blaze deferred.
|
||||
- **Slice 8 (2026-08-12): REAL-DATA staged retail A/B PASS (import + club + squad).** The real FIFA 17
|
||||
profile was imported into a staged Core via the two-store protocol (`openfut-import-fifa17 --apply`:
|
||||
generic transactional Core import + `openfut-identity` seeding, idempotent), then FIFA itself
|
||||
consumed it end-to-end over the source-scoped utas switch (`.105`→`.120:8199`). Imported population
|
||||
1949 of 1962 player instances (13 Legend/special assets deferred as unnameable — 9 NoName + the 4
|
||||
copies of `169193`), every original Python wire id preserved (set-equal, 0 minted/dropped), each
|
||||
owned instance an opaque Core UUID. On the retail client: real club rendered (1949, not the 33-card
|
||||
dev XI); `/club` pagination clean (paged==full, no loop/overlap/drop); the `Special` quality filter
|
||||
returned only specials (1665, 0 base leaked) and paginated the filtered set; a squad edit persisted
|
||||
to Core (canonical + opaque extension atomically, fingerprint recomputed) and survived a full cold
|
||||
relaunch; rollback→Python→Rust re-enable proven on the exact client path (disarm reached Python,
|
||||
re-arm returned the imported club + edited squad; identity store 0 reallocation). Three real-data
|
||||
fidelity gaps the base-only dev fixture had hidden were found on the live client and fixed:
|
||||
(1) `e187cd4` versioned `resourceId` — `shape_item` emitted the base assetId as `resourceId`,
|
||||
collapsing every special onto its base card art; `Fifa17Identity` now carries the versioned
|
||||
`resource_id`. (2) `626c972` observed `rareflag` — `shape_item` hardcoded `rareflag=1`, so all
|
||||
specials rendered as basic rare; `rareflag` now flows through the FIFA catalog and onto the wire
|
||||
(wire distribution == source exactly). (3) `6f16a23` `rare=SP` Special filter — previously a no-op
|
||||
("semantics UNKNOWN"), now grounded as `rareflag > 1` and applied host-side (Core has no rareflag).
|
||||
Also `44fcf24`: nation/league/team proven to be INSTANCE metadata (not definition identity — the 4
|
||||
copies of `169193` are identical but for club), so the definition-consistency gate now compares
|
||||
identity only (no `--defer-conflict` needed; no majority-vote). PRODUCTION NOT READY: the 13 deferred
|
||||
Legends are unnameable from the `.120` client dump (absent/placeholder in `players.json`; DLC/Legends
|
||||
name tables empty) — honest names require the FIFA 17 Legends locale data from the `.105` client.
|
||||
Next: resolve the 13 names → re-import to 1962/0 → full-fidelity gate → gitlink reconcile →
|
||||
production Rust UTAS.
|
||||
|
||||
## Stubbed / planned
|
||||
|
||||
- **`fifa-blaze`** (Rust) — Milestone 1 capture stub only. Two TLS listeners that log packets; no
|
||||
FIFA 23 component/command handlers. Its own README says IDs are unknown. Superseded in practice by
|
||||
the Python FIFA 17 responders, kept as the intended FIFA 23 implementation surface.
|
||||
- **`openfut-launcher` legacy controls** — core/bridge and FIFA 23 setup controls belong to a
|
||||
superseded plan. The launcher now also owns the live FIFA 17 client flow: server/hook config,
|
||||
account synchronization, local LSX, privileged autopatch, and game launch.
|
||||
- **`tools/`** (file-watch-diff, squad-injector, exporters) — helpers for the FLE-Lua-bridge idea in
|
||||
`docs/direction.md`. Not part of the live FIFA 17 path.
|
||||
- **`docs/foundational-xi-injection-test.md`** — a planned (not executed) test procedure for the FLE
|
||||
bridge route.
|
||||
|
||||
## Stubbed / blocked (FIFA 23 lineage)
|
||||
|
||||
- **`openfut-bridge`** — in-process `version.dll` hook on ProtoSSL. Git history: injection works but
|
||||
the effort hit an "architectural wall" (async event-driven gate, not a poll). Superseded first by
|
||||
the FLE-bridge pivot, then by the FIFA 17 route. Its `CLAUDE.md` task list is historical.
|
||||
|
||||
## Unknown / requires investigation
|
||||
|
||||
- Whether the FIFA 17 hub supports actually **playing a FUT match** offline and getting results back.
|
||||
- Whether FUT actions beyond the now-verified pack reveal/assignment flow (submit SBC, transfer
|
||||
market buy/sell, matches) round-trip correctly through `utas_server.py`.
|
||||
- The exact division of FUT state ownership once Core is wired in (who is source of truth).
|
||||
- Degree of FIFA 23 wire-format identity — asserted ("identical wire format") but the FIFA 23 client
|
||||
has not been re-tested against these responders in this repo's evidence.
|
||||
|
||||
## Known technical debt / hazards
|
||||
|
||||
- **Root docs are stale.** `README.md`, `CLAUDE.md`, `openfut-bridge/CLAUDE.md` all describe FIFA 23
|
||||
as the target and mark FIFA 23 integration as the open item — they predate the FIFA 17 success.
|
||||
- **All host state is volatile** across reboot except the `/etc/hosts` line — re-run
|
||||
`openfut-fut.sh start`. Requires `ptrace_scope=0` + root arming (security-relevant).
|
||||
- **Whole stack rides on EAAC staying neutralized** and game updates being off; a client update can
|
||||
break the memory patches (VAs) and cert bypass.
|
||||
- **`fifa17-recon/tools/lsx_responder_v2.py` is currently modified in the working tree** (uncommitted).
|
||||
- `33068179` / `CAGE` remains the responder fallback, but the launcher now blocks one-button launch
|
||||
until an explicit persona is configured and synchronized across LSX, Blaze, POW, and UTAS.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Python Retirement Readiness (post-P1)
|
||||
|
||||
Classification of every Python component still present. Evidence: prod-host
|
||||
`owner=` dispatch log (OBSERVED), AuxServiceMap/PythonContractMap scouts,
|
||||
container `entrypoint.sh`. **P2 rollback stays hot regardless** (do NOT remove
|
||||
rollback-to-python-p2.sh, :p2-rollback image b1b929953f, profile 39bb3e83).
|
||||
|
||||
## A. LIVE PRODUCTION REQUIRED (still owns behavior in the live flow)
|
||||
| Component | Role | Rust status | Retire when |
|
||||
|---|---|---|---|
|
||||
| oracle utas_server.py (:8199) NON-ECONOMY | **remaining**: user/club rename, non-active squad/<n>, draft/*, mode-gated leaderboards + /sbs/* | **Most non-economy migrated 2026-08-17** (account/sync, auth, userMassInfo, user, clientdata, hub, club/stats/*, settings, accountinfo, phishing, match/reset, leaderboards/options, watchList, static acks, item-defs (item/resource,defid), marketdata (+/pricelimits), and the flag-off empty reads season/tournament/champion/clubUser/user/list → `{}` — all Rust). See PRODUCTION_AUTHORITY_MATRIX | remaining tail is mutation (user/club), no-Core-model (squad/<n>), or unimplemented modes (draft/leaderboards/sbs) |
|
||||
| blaze_responder_v3b.py (:42130 Blaze) | FUT Blaze transport | Rust openfut-blaze-host COMPLETE, gate-proven (Gate 10) | container cutover (operator-gated deploy) |
|
||||
| blaze_responder_v3b.py (:42127 redirector TLS) | first-hop TLS redirect | Rust openfut-redirector-host COMPLETE (OpenSSL) | container cutover + cert consistency |
|
||||
| roster_server.py (:8081) | roster-update XML | Rust openfut-roster-host COMPLETE (unit-only) | container cutover |
|
||||
| pow_server.py (:8094/:8080) | Proof-of-Work / content | NO Rust crate; 58 templates, bodies un-reversed | needs Rust host + body RE (BLOCKED) |
|
||||
|
||||
## B. ORACLE ONLY (reference / differential, not production authority)
|
||||
- utas_server.py economy handlers: now oracle-only (economy is Rust; Python economy hits = 0). Used by
|
||||
`economy_differential.rs` as the parity oracle. KEEP.
|
||||
- fut_store.py / fut_accounts.py / fut_consumables.py: reference models + Ghidra-derived taxonomy source. KEEP.
|
||||
|
||||
## C. MIGRATION TOOL ONLY (offline)
|
||||
- scripts/*.py (seed_fifa17_cards.py, utas-observe/mutate/diff, check-*). KEEP (dev tooling).
|
||||
- fifa17-recon/tools/db_dump.py etc.: table extraction. KEEP.
|
||||
|
||||
## D. ROLLBACK ONLY (hot, DO NOT REMOVE)
|
||||
- rollback-to-python-p2.sh; image openfut-fut-backend:p2-rollback (b1b929953f) + :dev; profile 39bb3e83;
|
||||
compose/env backup; tuple OPENFUT_SERVERS="blaze roster utas pow".
|
||||
|
||||
## E. DEAD (advertised but not followed in current FIFA17 flow)
|
||||
- Nucleus /connect/token stub (:42131): 0 live hits across Gate 5-10 (client never POSTs). Keep the ADVERTISED
|
||||
URL so the client can't reach real EA, but no migration needed. No Rust listener required.
|
||||
- fifa-blaze (FIFA23 capture stub), openfut-bridge (FIFA23): retired lineage, not in FIFA17 flow.
|
||||
|
||||
## Retirement gating
|
||||
1. **Mostly DONE (2026-08-17)**: account/sync, ut/auth (Rust SID mint), userMassInfo (full), clientdata,
|
||||
club/stats/{country,league,team}, watchList, static acks, the flag-off empty reads
|
||||
(season/tournament/champion/clubUser/user/list → `{}`), item-defs (item/resource,defid), and
|
||||
marketdata (+/pricelimits, container-type-exact) migrated + deployed. **Remaining on Python**:
|
||||
user/club rename (mutating; needs a Core write), non-active squad/<n> (no multi-squad Core model),
|
||||
and the unimplemented modes draft/* + leaderboards + /sbs/* (need the mode logic ported, not a proxy).
|
||||
2. Deploy Rust blaze/roster/redirector via container cutover (operator-gated) — then those Python responders
|
||||
are class D/E only.
|
||||
3. POW: build Rust host + reverse bodies (largest blocker) OR keep Python POW as class A indefinitely.
|
||||
4. Only after a long stable window: consider retiring the P2 rollback (separate explicit decision — NOT now).
|
||||
@@ -0,0 +1,443 @@
|
||||
# FIFA 17 — Empty "My Packs" Client Contract (store/purchasegroup)
|
||||
|
||||
> **STATUS (2026-08-13): ROOT CAUSE ESTABLISHED; P2 backend compatibility workaround
|
||||
> IMPLEMENTED.** Root cause: FIFA 17's Store/Scaleform path resolves the `mypacks`
|
||||
> category even with zero unopened packs, and CardsDLL `FUN_1800147f0` assumes the
|
||||
> resolved group is non-null (crash if absent). Backend decision: **P2** — emit an
|
||||
> **active** non-openable synthetic `mypacks` placeholder (id 65534) when
|
||||
> `unopenedPackIds == []` (`fifa17-recon/tools/utas_server.py` `store_catalog`; tests
|
||||
> `fifa17-recon/tools/test_empty_mypacks.py`). Crash-safe + economy-safe; known UX
|
||||
> limitations (fake tile, click-dialog, Browse→My-Packs nav quirk) are Scaleform-driven
|
||||
> and require the client-side fix in `docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md`.
|
||||
> This is a FIFA-17-specific compatibility shim, NOT an EA-authentic representation,
|
||||
> and is confined to the FIFA-17 adapter/backend (NOT OpenFUT Core).
|
||||
|
||||
Evidence labels: **OBSERVED** (runtime capture / crash dump / already-decompiled RE
|
||||
quoted in-repo), **INFERRED**, **HYPOTHESIS**, **UNKNOWN**. No server behavior is
|
||||
changed by this document; it is analysis only.
|
||||
|
||||
Binaries (hashes verified 2026-08-13 on `.105`):
|
||||
`CardsDLL_Win64_retail.dll` SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c`
|
||||
(load base in the crash dump `0x00006FFFFC120000`; RE-space base `0x180000000`).
|
||||
`FIFA17.exe` SHA-256 `29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899` (packed).
|
||||
|
||||
## 1. Question
|
||||
|
||||
How must the server represent an account that owns **zero unopened packs** in
|
||||
`GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true` so that FIFA 17 neither
|
||||
(a) crashes nor (b) shows "The pack you've selected is currently not available",
|
||||
**without granting the user a real/openable pack** and without breaking the normal
|
||||
bronze/gold/special store? The current OpenFUT answer (a synthetic inactive `mypacks`
|
||||
sentinel) only downgrades a crash to a dialog; it is not the correct contract.
|
||||
|
||||
## 2. Established experimental behavior (OBSERVED)
|
||||
|
||||
Profile-state ladder, all with normal packs 1/5/6/7 unchanged:
|
||||
|
||||
| profile `unopenedPackIds` | `mypacks` group in response | client outcome |
|
||||
|---|---|---|
|
||||
| `[]` (baseline) | one **inactive** sentinel pack `65534`, empty description | "pack not available" dialog → FUT Hub |
|
||||
| `[70]` (Exp A) | one **active** real owned pack `70` | **store works**; My Packs visible |
|
||||
| `[]` + sentinel suppressed (Exp B) | **no `mypacks` group at all** | **client CRASH** |
|
||||
|
||||
Captures: `store_purchasegroup_capture_2026-08-12.json` (baseline),
|
||||
`…_mypacks70_2026-08-12.json` (A), `…_empty_no_sentinel_2026-08-12.json` (B).
|
||||
Details in `STORE_TILE_6C.md` §14-§15.
|
||||
|
||||
## 3. Existing RE evidence (from `docs/plan-2026-08-05-store-subsystem.md`, decompiled)
|
||||
|
||||
All addresses RE-space (CardsDLL base `0x180000000`):
|
||||
- **`FUN_18013af30`** — per-element pack deser; each `purchase[]` entry → a `0x158`
|
||||
wire record. `displayGroup.value`(atom 0x377) → record `+0x00` (ctor default the
|
||||
literal `"unknown"`). `displayGroup.priority`(0x250) → `+0x34`.
|
||||
(store-subsystem §"wire record", :996.)
|
||||
- **`FUN_1800150d0`** — group builder. Walks `purchase[]` in array order; for each
|
||||
pack, finds-or-creates a `0x108` display group by **exact strcmp of
|
||||
`displayGroup.value` against `group+0x70`** (`FUN_180014380`). New group
|
||||
(`FUN_180012950`): `+0x00` = 1-based ordinal (groups-so-far+1), `+0x100` =
|
||||
priority, **`+0x104` = (value == "mypacks")**, `+0x40` = vector of `0x1a8` tile
|
||||
models. Pack→tile via `FUN_18002c3c0`. (:152-161, :1042-1049.)
|
||||
- **`FUN_1800147f0`** — group **resolver/renderer**, called as
|
||||
`FUN_1800147f0(model, screen+0x290, dataProvider, 0, 0)` from `FUN_18007dab0`.
|
||||
`screen+0x290 == 0` → "list the group tiles" (`FUN_180014610`); **any other value
|
||||
→ `FUN_180014420`, which exact-matches `group+0x00` (the ordinal) and returns NULL
|
||||
on a miss, after which `FUN_1800147f0` dereferences `[RAX+0x40]` with NO guard**
|
||||
("checked in raw disassembly … a real absence"). **"The only legal category values
|
||||
are 0 and the ordinals 1..N."** (:163-169, :1051-1054.)
|
||||
- **`FUN_180014580`** — the six-tab bar: switch 0..5 over the hardcoded lowercase
|
||||
literals `mypacks, points, bronze, silver, gold, special`. `FUN_18007e5e0` gives
|
||||
each panel a `PANEL_ID` = the matching group's ordinal, **or HIDES the panel** if no
|
||||
matching group; `FUN_18007df60` publishes `MYPACK_/…/SPECIAL_CATEGORY_ID`.
|
||||
(:202-206, :1058-1062.)
|
||||
|
||||
## 4. Store parser code path (OBSERVED, decompiled)
|
||||
|
||||
```
|
||||
HTTP 200 {"purchase":[...]} (server: store_catalog / _pack_body)
|
||||
→ per-element deser FUN_18013af30 → 0x158 wire records
|
||||
→ FUN_1800150d0 → 0x108 display groups (by displayGroup.value),
|
||||
tiles (0x1a8, FUN_18002c3c0) into group+0x40
|
||||
→ render FUN_18007dab0 → FUN_1800147f0(model, screen+0x290, …)
|
||||
screen+0x290 == 0 → FUN_180014610 list this group's tiles
|
||||
screen+0x290 == N>0 → FUN_180014420 exact-match ordinal; NULL on miss
|
||||
→ [RAX+0x40] dereference (NO NULL GUARD) ← crash site
|
||||
```
|
||||
|
||||
## 5. My Packs group construction (OBSERVED)
|
||||
|
||||
A `mypacks` group exists **iff at least one `purchase[]` entry carries
|
||||
`displayGroup.value == "mypacks"`** (the group is derived from packs; there is no
|
||||
independent group object on the wire). Its ordinal is its 1-based creation position
|
||||
in array order; `group+0x104` is set because the value is `"mypacks"`; its tiles live
|
||||
in `group+0x40`. Consequently the server **cannot** emit an "empty `mypacks` group"
|
||||
via `purchase[]` — removing the pack removes the group entirely.
|
||||
|
||||
## 6. Default / selected group logic (partly UNKNOWN)
|
||||
|
||||
- `FUN_1800147f0` resolves whatever category ordinal it is handed via `screen+0x290`;
|
||||
legal values are `0` (list tiles) and `1..N` (existing ordinals). A value that is
|
||||
not an existing ordinal (e.g. `-1` for a hidden/absent panel) → `FUN_180014420`
|
||||
NULL → crash. (OBSERVED via §8 crash + RE.)
|
||||
- **Whether the store defaults to / auto-resolves the `mypacks` category on open, and
|
||||
why it does so even when the unopened count is 0, is UNKNOWN** — the store-screen
|
||||
controller and default-tab selection are Scaleform/packed-FIFA17.exe
|
||||
("Which tile the movie thinks you clicked | CLIENT | Scaleform, unread",
|
||||
store-subsystem :695). Experiment B proves that in this configuration the client
|
||||
DID resolve a `mypacks` ordinal that did not exist (it crashed), so the store is
|
||||
reaching the My Packs category with zero owned packs. Root of "why" = UNKNOWN.
|
||||
|
||||
## 7. Pack availability predicate (UNKNOWN)
|
||||
|
||||
The predicate that turns the baseline inactive sentinel into "pack not available"
|
||||
(while active pack 70 passes) is **in the packed FIFA17.exe and unread**
|
||||
(`plan-2026-08-05-pack-opening.md:553`: `state/saleType/quantity/purchaseLimit/
|
||||
purchaseCount/start/end` are parsed and copied to the tile, "the predicate that greys
|
||||
a tile is in the packed exe and unread"). Candidate deciding fields, from the
|
||||
baseline↔A diff (INFERRED, unproven): **`state` (`inactive`→`active`)** and/or
|
||||
**`unopened` (`false`→`true`)**. The exact field is **UNKNOWN**.
|
||||
|
||||
## 8. Experiment B crash analysis (OBSERVED)
|
||||
|
||||
Minidump `CrashDump_…18.21.08…dmp` (the only crash in the hour; minute `:21` matches
|
||||
the `00:21:07` store request; SHA-256 `fbddda18…`), parsed:
|
||||
- Exception: **`0xC0000005` ACCESS_VIOLATION**, access type **READ**, **faulting VA
|
||||
`0x0000000000000048`**.
|
||||
- Faulting instruction: **`CardsDLL_Win64_retail.dll + 0x14882`** → RE-space
|
||||
**`0x180014882`** = **`0x92` bytes into `FUN_1800147f0`** (entry `0x1800147f0`).
|
||||
- Interpretation (OBSERVED crash ⋂ decompiled RE): the group pointer returned by
|
||||
`FUN_180014420` was **NULL** (no `mypacks` group present with `unopenedPackIds==[]`
|
||||
and the sentinel suppressed), and `FUN_1800147f0` dereferenced `[NULL+0x48]` → read
|
||||
of address `0x48` → access violation. This is exactly the "no null guard" branch
|
||||
the RE flagged (RE said `[RAX+0x40]`; the actual faulting offset is `+0x48`, same
|
||||
member region — the group struct's `+0x40` vector accessed via a `+0x48` field).
|
||||
- Note: the pre-built Ghidra project and `/tmp/fut/cardsdll.dll` were absent on `.105`
|
||||
(tmp cleared); confirmation used the OBSERVED crash dump + the previously-decompiled
|
||||
RE rather than a fresh (expensive) re-analysis. CardsDLL is unpacked, so this code
|
||||
is statically readable if a fresh project is ever needed.
|
||||
|
||||
## 9. Empty My Packs client contract (the answer, as far as evidence allows)
|
||||
|
||||
- **The client has NO guard for a missing selected group.** If the store resolves the
|
||||
`mypacks` category and no `mypacks` group exists, it null-derefs and crashes.
|
||||
(OBSERVED.)
|
||||
- **A `mypacks` group can only exist if a `purchase[]` entry carries
|
||||
`displayGroup.value=="mypacks"`.** (OBSERVED.) There is no wire representation of an
|
||||
"empty group."
|
||||
- **If the `mypacks` group's selected pack is not a valid/active pack, the client
|
||||
shows "pack not available".** (OBSERVED baseline vs A; deciding field UNKNOWN, §7.)
|
||||
- Therefore, under the *current* client behavior, a zero-unopened-packs account is
|
||||
only cleanly handled when the `mypacks` group contains a **valid active pack**
|
||||
(Exp A). Whether an active-but-non-openable placeholder would also satisfy the
|
||||
availability predicate is **UNKNOWN** (depends on §7).
|
||||
- **The correct retail-EA representation of zero unopened packs is UNKNOWN.** It is
|
||||
NOT "omit the group" (crash) and NOT "inactive placeholder" (dialog). It is most
|
||||
likely one of: (i) the retail store does not auto-select My Packs when the unopened
|
||||
count is 0 (a client/Scaleform decision, possibly gated by a count the server sets),
|
||||
or (ii) retail sends a `mypacks` entry the client treats as an empty-but-valid state
|
||||
via a field we have not identified. Neither is established.
|
||||
|
||||
## 10. Candidate server representations
|
||||
|
||||
| # | Candidate | Client evidence | Expected behavior | Confidence | Safe to test? |
|
||||
|---|---|---|---|---|---|
|
||||
| A | No `mypacks` pack, no `mypacks` group | Exp B crash (`0x180014882`, `[NULL+0x48]`) | **CRASH** | OBSERVED | Already tested — reject |
|
||||
| B | `mypacks` group present but zero packs | Not representable via `purchase[]` (groups derive from packs, `FUN_1800150d0`) | UNKNOWN | INFERRED-not-representable | No server mechanism |
|
||||
| C | Inactive placeholder pack (current sentinel) | Baseline dialog | "pack not available" → Hub | OBSERVED | Already tested — reject |
|
||||
| C′ | **Active** placeholder pack, id absent from `PACK_CATALOG` (so open/buy handlers reject it) | Exp A shows an *active* mypacks pack works; sentinel id 65534 is already rejected by open/buy (not in `PACK_CATALOG`) | Group resolves (no crash); MIGHT pass availability (no dialog) while remaining non-openable → no free pack | HYPOTHESIS | Yes — code change, restart; economy-safe (non-openable) |
|
||||
| D | Hide My Packs when unopened count == 0 | `FUN_18007e5e0` hides a panel with no group, but the store still resolved `mypacks` at count 0 (Exp B crash) | Hiding via absence CRASHES; a client count-gate is Scaleform/unknown | UNKNOWN | Not server-controllable as far as known |
|
||||
| E | Different default category when count == 0 | Default-tab selection is Scaleform/packed | UNKNOWN | UNKNOWN | Not server-controllable as far as known |
|
||||
| F | A count/quantities field (e.g. `ut/v2/store` `FutStorePackQuantities`, or `userInfo.unopenedPacks`) that suppresses the My Packs auto-select | eligibility gate exists (`ENDPOINT_MAP.md:60`); relationship to My Packs default UNKNOWN | UNKNOWN | HYPOTHESIS | Read-only RE first |
|
||||
|
||||
## 11. Recommended next controlled experiment
|
||||
|
||||
**Experiment C′ (economy-safe placeholder).** Keep `unopenedPackIds == []`; change the
|
||||
synthetic sentinel `65534` ONLY in `state` (and, if needed, `unopened`) so the
|
||||
`mypacks` group's single tile is **active** — but leave its id `65534` **absent from
|
||||
`PACK_CATALOG`** so `store_buy`/`open_pack`/`consume_unopened_pack` still reject it
|
||||
(no pack can be opened → **no free pack, no economy change**). Observe whether the
|
||||
store then opens without the "pack not available" dialog (would identify `state` as
|
||||
the availability field and give an economy-safe fix), or still shows the dialog
|
||||
(implicating another field / packed predicate).
|
||||
- Requires a temporary code change to `store_catalog` (sentinel construction) →
|
||||
restart. Same experiment discipline as Experiment B (patch container copy, capture,
|
||||
revert, restart). Economy-safe because the placeholder remains non-openable.
|
||||
- If C′ still fails, escalate to read-only RE of the availability predicate / the
|
||||
count-gated default-tab hypothesis (candidate F) before any further change.
|
||||
|
||||
## 12. Open questions
|
||||
|
||||
1. Exact field that flips the sentinel from "pack not available" to acceptable
|
||||
(`state`? `unopened`? another). UNKNOWN — packed predicate. (Exp C′ targets this.)
|
||||
2. Why does the store resolve/select `mypacks` with zero owned packs? Is there a
|
||||
server-settable count that would stop it? UNKNOWN — Scaleform/packed.
|
||||
3. Does retail FIFA 17 ever present an empty My Packs, and how? No capture on record.
|
||||
4. Is an active-but-non-openable placeholder (C′) accepted by the availability
|
||||
predicate? HYPOTHESIS — untested.
|
||||
|
||||
---
|
||||
|
||||
## Permanent-fix requirements (Phase 11 — REPORT ONLY, not implemented)
|
||||
|
||||
A correct permanent fix MUST satisfy ALL of:
|
||||
- Zero unopened packs must **NOT** grant the user a free pack.
|
||||
- No synthetic **openable** reward may be created (any placeholder must be rejected by
|
||||
`store_buy`/`open_pack`/`consume_unopened_pack`).
|
||||
- Client must **not crash** (a resolvable `mypacks` group must exist, OR the client
|
||||
must be kept from resolving `mypacks` when empty).
|
||||
- Client must **not** show "The pack you've selected is currently not available".
|
||||
- Normal Bronze/Gold/Special store categories must still work unchanged.
|
||||
- When a genuine unopened pack exists, My Packs must continue to work (Exp A).
|
||||
- Profile/economy semantics must remain correct (no coins/nextItemId/inventory drift).
|
||||
|
||||
Nothing implemented. The evidence favours investigating an **economy-safe active
|
||||
placeholder (C′)** and/or the **count-gated My-Packs default (F)**; it explicitly does
|
||||
NOT support "grant pack 70 whenever My Packs is empty".
|
||||
|
||||
---
|
||||
|
||||
## UPDATE after Experiment C′ (2026-08-13) — active non-openable placeholder tested
|
||||
|
||||
Executed C′: sentinel 65534 `state "inactive"→"active"` only; `unopenedPackIds==[]`;
|
||||
65534 kept out of `PACK_CATALOG`. Full record in `STORE_TILE_6C.md` §16. Capture:
|
||||
`store_purchasegroup_capture_active_placeholder_2026-08-12.json` (C′-vs-baseline JSON
|
||||
diff = only `65534.state`).
|
||||
|
||||
**Resolves §7 (availability predicate), partially:** `state` **DOES participate**
|
||||
(OBSERVED). `state:"active"` removed the "pack not available" dialog while the group's
|
||||
existence still prevented the crash. So the earlier §7 "deciding field UNKNOWN" is
|
||||
updated: **`state` (inactive vs active) is (at least) a deciding field** for the
|
||||
dialog. `unopened` was NOT varied and remains untested. The full predicate may still
|
||||
involve other fields, but `state` alone flips dialog→no-dialog.
|
||||
|
||||
**Updated candidate table verdict:**
|
||||
- **C′ (active placeholder, non-openable): SUPPORTED with UX caveats — best option so
|
||||
far, but NOT adopted.** No crash, no dialog, store usable, and **no automatic
|
||||
transaction/open for 65534** (only a routine boot `TRANSACTIONCANCEL` no-op).
|
||||
Caveats (OBSERVED): (1) the placeholder renders as a **visible empty pack tile**
|
||||
("0 items, 0 bronze, 0 rares", no cover) that a user could try to open (server-safe:
|
||||
opening 65534 → no-op `{}`/stale `last_pack`, no value — §16.1); (2) **navigation
|
||||
gate**: from the Store "Browse Packs" entry the Bronze/Gold/Special categories are
|
||||
not reachable until "My Packs" is opened first (not present with a genuine owned
|
||||
pack, Exp A).
|
||||
- A (real active pack 70): works cleanly but grants a real openable pack → economy
|
||||
risk; rejected as the permanent fix.
|
||||
- Candidate **F (count-gated My-Packs default)** gains weight: C′'s visible-empty-tile
|
||||
and Browse-Packs navigation gate suggest the client is being pushed to resolve/enter
|
||||
My Packs when it should not with zero packs. If a server-settable count (e.g.
|
||||
`userInfo.unopenedPacks` / `ut/v2/store` quantities) suppresses the My-Packs
|
||||
default/tile, that could remove both the crash risk and the empty-tile artifact
|
||||
without any placeholder. UNTESTED.
|
||||
|
||||
**Permanent fix: still NOT established.** Even though C′ is the first
|
||||
crash-free/dialog-free representation, the empty-tile UX + navigation gate + the
|
||||
untested "explicit placeholder selection" behavior bar adoption. Required next steps
|
||||
(design/authorize separately): (a) controlled test of explicitly focusing/opening the
|
||||
active placeholder; (b) investigate candidate F (count-gated My-Packs) to avoid a fake
|
||||
tile entirely. Do NOT adopt `state:"active"` or "grant pack 70" as the fix on current
|
||||
evidence.
|
||||
|
||||
---
|
||||
|
||||
# Candidate F — Count-Gated My Packs Navigation (READ-ONLY investigation, 2026-08-13)
|
||||
|
||||
Question: can the server make FIFA decide **not** to resolve/default into My Packs
|
||||
when the account owns zero unopened packs (avoiding any placeholder)?
|
||||
|
||||
## 1. Server-sent unopened-pack signals (inventory, OBSERVED code)
|
||||
| field / endpoint | source | value source | when sent | client consumer | conf |
|
||||
|---|---|---|---|---|---|
|
||||
| `userInfo.unopenedPacks.recoveredPacks` (via `userMassInfo`) | `utas_server.py:409-414` | `len(unopenedPackIds)` | boot massinfo; only if count>0 **or** `_UI∈{packs,full}` (default `_UI=roster` → omitted at 0) | hub unopened-pack model / My Packs badge (`FUN…vtbl[0x4e0]`, pack-opening RE) | OBSERVED (code) |
|
||||
| `/user/credits` `.unopenedPacks.recoveredPacks` | `utas_server.py:3542-3545` | `len(unopenedPackIds)` | on credits fetch; **only if count>0** | My Packs badge / CentralUnclaimedPack hub tile | OBSERVED (code+capture) |
|
||||
| `/hub` body | `utas_server.py:1449-1453` | — | hub load | — (**no pack count present**) | OBSERVED |
|
||||
| profile `unopenedPackIds` | `fut_store.py` | account state | internal | not wire-visible directly | OBSERVED |
|
||||
`pileSize`/`store quantities` (`ut/v2/store` `FutStorePackQuantities`) exist as an
|
||||
eligibility gate (`ENDPOINT_MAP.md:60`) but were **never requested** in any capture
|
||||
(8h logs); they carry a store-open `result`, not a My-Packs count.
|
||||
|
||||
## 2. Pre-store request sequence (OBSERVED, captures)
|
||||
Boot → `accountinfo → /ut/auth → settings → phishing → match/reset → userMassInfo →
|
||||
PUT store/transaction/0 (TRANSACTIONCANCEL→{}) → /hub → clientdata → /user/credits →
|
||||
GET /store/purchasegroup/all`. The only pack-count-bearing responses **before**
|
||||
`purchasegroup` are `userMassInfo`(userInfo) and `/user/credits`.
|
||||
|
||||
## 3. Baseline([]) vs Experiment A([70]) pre-store diff (OBSERVED, captures)
|
||||
The single profile change `[] → [70]` altered exactly one pre-store wire signal:
|
||||
- `/user/credits`: **`[]` → no `unopenedPacks` member** (C′ capture, all 3 fetches:
|
||||
`{"credits":…,"currencies":[…]}`); **`[70]` → `"unopenedPacks":{"preOrderPacks":0,
|
||||
"recoveredPacks":1}`** (A capture line 25). OBSERVED.
|
||||
- `userInfo.unopenedPacks`: same pattern (present at `[70]`, omitted at `[]` with
|
||||
`_UI=roster`). INFERRED from code; A-capture credits corroborates.
|
||||
- **No other pre-store field changed.**
|
||||
|
||||
Candidate signal:
|
||||
```
|
||||
Candidate: unopenedPacks.recoveredPacks (count)
|
||||
Endpoint: /user/credits and userMassInfo(userInfo)
|
||||
Baseline([]) value: ABSENT (i.e. zero)
|
||||
Experiment A([70]) value: {preOrderPacks:0, recoveredPacks:1}
|
||||
Source: utas_server.py:3542-3545 / :409-414 (= len(unopenedPackIds))
|
||||
Client-visible before purchasegroup?: YES
|
||||
Confidence: OBSERVED that it differs; its CONTROL over My-Packs nav = see §9
|
||||
```
|
||||
**Key point: this count is already CORRECT** — it reports zero (absent) when the
|
||||
account is empty. OpenFUT is **not** misreporting a nonzero pack count.
|
||||
|
||||
## 4. Navigation / client call path (OBSERVED, decompiled RE)
|
||||
`FUN_18007dab0 → FUN_1800147f0(model, screen+0x290, …)`. `screen+0x290==0` lists
|
||||
group tiles; else `FUN_180014420` exact-matches the group ordinal (NULL on miss →
|
||||
`[NULL+0x48]` crash). `screen+0x290` is written in exactly two CardsDLL sites: the
|
||||
screen ctor `FUN_18007d1a0` writes `0`, and **`FUN_18007e7f0` case `0x7551` copies
|
||||
the Flash movie message field `CATEGORY_ID` verbatim** into it
|
||||
(store-subsystem :172-175, :1055-1056). So the resolved category is chosen by the
|
||||
**Scaleform movie**, not by any server response field.
|
||||
|
||||
## 5. `GOTO_STORE_MYPACK` analysis (OBSERVED, RE)
|
||||
`GOTO_STORE_MYPACK` is the **destination of the hub `CentralUnclaimedPack` tile**
|
||||
(tile type 0x1c); "Nothing in the chain issues a request, and no request could
|
||||
exist" (pack-opening :888-890). Whether that HUB tile appears is gated by the
|
||||
unopened-pack count in the hub model (`model+0x20950`) — i.e. the count DOES control
|
||||
the *hub unclaimed-pack tile*, but the operator reached the store via **Browse Packs**
|
||||
/ the store screen, whose category resolution is the movie-driven `CATEGORY_ID` path
|
||||
(§4), not `GOTO_STORE_MYPACK`. `GOTO_STORE_MYPACK` is a UI navigation command,
|
||||
**not** a server-state-gated store-category selector.
|
||||
|
||||
## 6. Candidate count/flag fields — verdict per field
|
||||
- `unopenedPacks.recoveredPacks`: correct at 0 when empty; controls the hub badge /
|
||||
CentralUnclaimedPack tile, **not** the store's category resolver. Not a viable gate
|
||||
for the store My-Packs entry.
|
||||
- No other server field feeds `screen+0x290` (RE §4: only ctor-0 and movie
|
||||
`CATEGORY_ID`).
|
||||
|
||||
## 7. EA-capture evidence
|
||||
No EA-origin `purchasegroup`/`credits` capture for a zero-unopened-packs account
|
||||
exists in the repo (all captures are OpenFUT-generated). EA count semantics for empty
|
||||
My Packs remain **UNKNOWN**.
|
||||
|
||||
## 8. Where My Packs selection occurs (OBSERVED)
|
||||
**Before** `purchasegroup` parsing decides content, the **Scaleform movie** decides
|
||||
which category to resolve and writes it to `screen+0x290` (§4). The server's role is
|
||||
limited to which groups EXIST in `purchase[]`. Therefore the sentinel is compensating
|
||||
for a **movie-side** decision to resolve My Packs; it is not fixing an incorrect
|
||||
server count (the count is already correct).
|
||||
|
||||
## 9. Candidate F verdict — **F3 (CONTRADICTED)** (with an F4 residue)
|
||||
My Packs selection is **not** controlled by server-sent unopened-pack state:
|
||||
- OBSERVED: the server count is correctly zero/absent when empty, yet the store still
|
||||
resolved My Packs (baseline dialog, Exp-B crash). A correct zero signal did not stop
|
||||
it.
|
||||
- OBSERVED (RE): `screen+0x290` (the resolved category) comes from the movie's
|
||||
`CATEGORY_ID`, with no server-field input; default is 0.
|
||||
Residue (F4): the movie's internal logic for *why* it asks for My Packs on store open
|
||||
is in packed Scaleform and is not statically readable — but no server lever into it
|
||||
has been found. **Conclusion: there is no server-controlled count/flag that makes FIFA
|
||||
skip resolving My Packs; the server can only ensure the `mypacks` group exists.** The
|
||||
"clean count-gated fix" is therefore **not achievable server-side**.
|
||||
|
||||
## 10. Proposed next experiment
|
||||
Because F is contradicted, a count experiment is NOT recommended (the count is already
|
||||
correct and does not gate the store). No single-variable server signal will make FIFA
|
||||
enter Browse Packs instead of My Packs. The realistic next step is the previously
|
||||
deferred **explicit active-placeholder selection test**: with the C′ active
|
||||
non-openable placeholder in place (sentinel 65534 at baseline otherwise), have the
|
||||
operator explicitly focus/open the empty My-Packs tile and observe (server-safe per
|
||||
§16.1 — opening 65534 is a no-op — but UX/navigation behavior unknown). That
|
||||
characterizes the best available server-side option (C′) before any adoption.
|
||||
- Would it change profile state? No (`unopenedPackIds=[]`).
|
||||
- Would it change purchasegroup/sentinel behavior? Only `state:"active"` (as C′),
|
||||
reverted after.
|
||||
- Code change? Yes (same one-line C′ patch). Restart? Yes. (Not authorized here.)
|
||||
If explicit selection proves unsafe/ugly, the remaining options are all **client-side
|
||||
/ out-of-scope** (the decision is in the Scaleform movie), or accepting C′ with its
|
||||
documented UX artifacts.
|
||||
|
||||
**Candidate F does NOT provide the hoped-for clean fix. The active non-openable
|
||||
placeholder (C′) remains the best server-side representation; its empty-tile and
|
||||
Browse-Packs navigation artifacts are movie-driven and not server-fixable.**
|
||||
|
||||
---
|
||||
|
||||
# Explicit Active-Placeholder Selection Test — FINAL backend-side result (2026-08-13)
|
||||
|
||||
With the C′ active placeholder in place (`unopenedPackIds=[]`, 65534 active/mypacks/
|
||||
∉PACK_CATALOG), the operator explicitly opened the empty My-Packs tile once. Full
|
||||
record in `STORE_TILE_6C.md` §17.
|
||||
- **Outcome: S1 — pure client-side rejection.** Dialog **"This pack is no longer
|
||||
available"** → back to My Packs → Hub; **no crash**, navigation stays usable.
|
||||
- **No server request** on selection (no `/store/transaction`, no `/purchased/items`,
|
||||
no 65534 reference); the verdict is client-side. (OBSERVED)
|
||||
- **Zero economy/profile mutation:** coins/items/nextItemId/`unopenedPackIds`/
|
||||
`last_pack` all unchanged; profile byte-identical (`39bb3e83…`); 65534 not
|
||||
persisted. (OBSERVED)
|
||||
|
||||
**Active-placeholder verdict: MARGINALLY ACCEPTABLE** — crash-safe + economy-safe +
|
||||
navigable, but with user-visible defects (empty fake tile; "no longer available" on
|
||||
explicit click; Browse-Packs nav gate). It is a *strict improvement* over the current
|
||||
inactive-sentinel baseline (which errors on store OPEN and bounces to Hub).
|
||||
|
||||
**Backend-side question is now fully answered.** The complete zero-unopened-packs
|
||||
ladder:
|
||||
```
|
||||
no mypacks group -> CardsDLL null-deref CRASH (unsafe)
|
||||
inactive placeholder -> "pack not available" on store open -> Hub (baseline)
|
||||
active placeholder -> store loads; empty tile; "no longer available" only on
|
||||
explicit click; recoverable; economy-safe (best backend option)
|
||||
real active owned pack -> fully correct UI (but grants a real openable pack — economy risk)
|
||||
```
|
||||
|
||||
**Permanent-fix recommendation: P2.** The active non-openable placeholder is the best
|
||||
*safe* backend-only option, but a fully *clean* zero-pack experience is **not**
|
||||
achievable server-side (Candidate F CONTRADICTED — the My-Packs resolution is
|
||||
Scaleform/movie-driven). Recommend: adopt the active placeholder as an optional
|
||||
backend compatibility mode (safe, strictly better than baseline) AND pursue a
|
||||
client-side fix (hide the fake tile / stop the forced My-Packs resolution) for the
|
||||
fully clean result. **Not implemented.** Do NOT grant a real pack.
|
||||
|
||||
---
|
||||
|
||||
## Client resolver-guard experiment (2026-08-13) — RESULT F3 (crash, confounded)
|
||||
|
||||
A client-side `autopatch.py` memory guard (CardsDLL `0x180014858` `JNZ`→`JG`, routing
|
||||
category `<0` to list-all/Browse) was tested against the exact no-sentinel server condition
|
||||
(sentinel 65534 suppressed; `GET /store/purchasegroup` ids `[1,5,6,7]`, no mypacks group).
|
||||
The client **crashed at the identical resolver site `0x180014882`** (`[NULL+0x48]`), because
|
||||
it presented a **positive** My-Packs ordinal (crash is in the `>0` resolve branch), not the
|
||||
`-1` the guard diverts. **Confound:** FIFA was not relaunched after the backend flip, so it
|
||||
reused stale (sentinel-present) tab state. So the negative-only guard is **insufficient for a
|
||||
positive stale/invalid ordinal**, and the fresh-client case is **not yet decided** (needs a
|
||||
clean re-test: fresh launch with backend already no-sentinel). Backend P2 sentinel was
|
||||
restored immediately (mandatory rollback). Full record + candidate stronger guard:
|
||||
`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART III.
|
||||
|
||||
## Fresh-process no-sentinel retest (2026-08-13) — RESULT R1 (SUCCESS)
|
||||
|
||||
Re-ran the above cleanly: backend entered no-sentinel mode **while FIFA was closed**, then a
|
||||
**fresh** FIFA (pid 553220, new autopatch 552999, guard `85 ff 7f 0f` enforced) launched and
|
||||
opened the Store. The genuine no-sentinel `/store/purchasegroup` (ids `[1,5,6,7]`, no 65534/
|
||||
mypacks) is **byte-identical** to the F3 capture, so the only changed variable is client
|
||||
process lifetime. Outcome: **no crash, no dialog, Store opens on Browse Packs, packs
|
||||
navigable** (cosmetics only: no tabs / no cover art / "0 items" — pre-existing). A fresh
|
||||
client publishes category `-1` for the absent group, which `JNZ→JG` routes to Browse/list-all
|
||||
with no NULL deref. **This confirms F3 was stale-positive-ordinal contamination, and proves
|
||||
Strategy A (resolver guard) on the tested build.** Backend P2 sentinel restored immediately
|
||||
(`f416e71e…`, `state=active`) and remains production default. Full record:
|
||||
`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART IV.
|
||||
@@ -0,0 +1,924 @@
|
||||
# Store Tile Investigation (bug 6c)
|
||||
|
||||
Status: **ROOT CAUSE ESTABLISHED — P2 compatibility workaround IMPLEMENTED**
|
||||
(2026-08-13). Full investigation complete (§1-§17); backend fix landed in
|
||||
`fifa17-recon/tools/utas_server.py` `store_catalog` with regression tests in
|
||||
`fifa17-recon/tools/test_empty_mypacks.py`. The store-tiles flags are unchanged
|
||||
(`FUT_STORE_DISPLAYGROUP=ON`, `FUT_STORE_GROUPID=OFF`) and the profile is unchanged.
|
||||
|
||||
## RESOLUTION (2026-08-13)
|
||||
|
||||
**ROOT CAUSE (bug 6c):** FIFA 17's Store/Scaleform path RESOLVES the `mypacks`
|
||||
category even when the account owns zero unopened packs (the category is chosen
|
||||
client-side from the movie's `CATEGORY_ID` → `screen+0x290`; no server field gates
|
||||
it — Candidate F CONTRADICTED). CardsDLL `FUN_1800147f0` then dereferences the
|
||||
resolved group with NO null guard, so an absent `mypacks` group crashes the client
|
||||
(`CardsDLL_Win64_retail.dll+0x14882`, `[NULL+0x48]` — minidump-confirmed, §8).
|
||||
|
||||
**BACKEND RESULT / DECISION — P2 (compatibility workaround):** emit a synthetic,
|
||||
**active**, non-openable `mypacks` placeholder (id 65534, absent from `PACK_CATALOG`)
|
||||
only when `unopenedPackIds == []`. This is crash-safe AND economy-safe (explicit
|
||||
selection is rejected client-side with "This pack is no longer available", sends no
|
||||
backend request, and mutates nothing — §17). It is a FIFA-17 client-compatibility
|
||||
shim, **NOT** an EA-authentic empty-My-Packs representation, and is confined to the
|
||||
FIFA-17 adapter/backend layer (NOT OpenFUT Core).
|
||||
|
||||
**KNOWN UX LIMITATIONS (unfixable server-side):** a fake empty "0 items" tile; an
|
||||
explicit-selection dialog "This pack is no longer available"; and a Browse-Packs →
|
||||
My-Packs navigation quirk. These are Scaleform/movie-driven.
|
||||
|
||||
**CLEAN CLIENT FIX:** still unresolved; belongs to client-side work —
|
||||
`docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md`.
|
||||
|
||||
Evidence labels: **OBSERVED** (running code / `docker inspect` / live log / minidump /
|
||||
table), **INFERRED**, **HYPOTHESIS**, **UNKNOWN**.
|
||||
|
||||
### Hypotheses
|
||||
|
||||
- **H1 (original — subtype/definition):** *Store tiles render "unknown" because the
|
||||
server emits definitions whose type/subtype the client cannot map (prime suspects
|
||||
5004xxx misc {231,232,233,236}, 8010xxx league logos, FCC↔wire subtype gaps).*
|
||||
**Verdict: CONTRADICTED by static store-handler evidence.** The `/store/purchasegroup`
|
||||
handler emits PACK definitions only — no `cardsubtypeid`/`carddbid`/`cardassetid`
|
||||
anywhere in the response (§2). Those subtype families belong to the separate
|
||||
**club-item / consumable / equippable** paths (`fut_clubitems.py`, `fut_consumables`,
|
||||
`/club?type=`), which are OUT OF SCOPE for this store-tile task. History preserved
|
||||
in §3.3 and §8; not investigated further here.
|
||||
- **H2 (revised — displayGroup token):** **HYPOTHESIS (under runtime test).** *The
|
||||
"unknown" FUT Store tile is caused by one or more pack entries whose
|
||||
`displayGroup.value` token is not one of the six categories FIFA 17 can render:*
|
||||
`mypacks, points, bronze, silver, gold, special`. Tested against a real capture in
|
||||
§4-§8.
|
||||
|
||||
---
|
||||
## Runtime capture plan (Phase 2) — OBSERVED
|
||||
|
||||
- **Backend container:** `openfut-fut-backend` (logs on stdout via `log()`,
|
||||
`utas_server.py:59-62`; each request logs `"<VERB> <path>"` at `:3732`, and the
|
||||
response line `" -> <code> <body[:200]>"` at `:3754` — **response body truncated
|
||||
to 200 bytes**, so the full JSON body is NOT in the log).
|
||||
- **Endpoint / path matcher:** `GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true`
|
||||
(OBSERVED historically in the log; route regex `/store/purchasegroup`,
|
||||
`utas_server.py:1215`).
|
||||
- **Capture marker (UTC, set immediately before the manual test):**
|
||||
`2026-08-12T23:54:01Z` (saved to `/tmp/store_capture_marker.txt`; 0 log lines
|
||||
after it at set-time → clean boundary).
|
||||
- **Log command to isolate the manual action:**
|
||||
`docker logs --since 2026-08-12T23:54:01Z --timestamps openfut-fut-backend`
|
||||
then locate the first `GET .../store/purchasegroup` line after the marker plus its
|
||||
following headers and `-> 200 {"purchase"...` line.
|
||||
- **Full-body recovery (because the log truncates at 200 bytes):** the response is a
|
||||
deterministic pure function — `store_catalog()` (`:3408`) over static `PACK_CATALOG`
|
||||
(`fut_store.py:820`) + live `STORE.unopened_packs()` (from `/state` profile) under
|
||||
fixed flags (`STORE_DISPLAYGROUP=ON`, `STORE_GROUPID=OFF`, `FUT_PRICE_PROBE=OFF`).
|
||||
Plan: reconstruct the exact body from the running `/app` code + live `/state`
|
||||
profile, then **verify** its `json.dumps(...)[:200]` byte-for-byte equals the genuine
|
||||
logged 200-byte prefix. Match ⇒ the reconstruction IS the sent body. No request is
|
||||
synthesized, replayed, or curl'd; the genuine log line is the ground-truth anchor.
|
||||
|
||||
|
||||
## 1. Method
|
||||
|
||||
### Environment (OBSERVED)
|
||||
- Running on `10.10.0.120` (dev-lxc). Client is `10.10.0.105`.
|
||||
- Backend under test: Docker container `openfut-fut-backend`
|
||||
(image `openfut-fut-backend:dev`), `Up 7 hours`, entrypoint `/app/entrypoint.sh`.
|
||||
- `docker inspect openfut-fut-backend`: only mount is
|
||||
`/home/alex/OpenFUT/fifa17-recon/docker/state -> /state (rw)`; container ENV
|
||||
contains **no `FUT_STORE_*`** vars.
|
||||
- `entrypoint.sh` launches `python3 -u utas_server.py` from `/app/tools` with extra
|
||||
env `FUT_TRADING=1 FUT_PILESIZES=1 FUT_TRADEABLE=1 FUT_DISCARD_TABLE=1
|
||||
FUT_DISCARD_SEND=1` — again **no `FUT_STORE_*`**.
|
||||
- The running store code is `/app/tools/utas_server.py`. Extracted read-only via
|
||||
`docker cp openfut-fut-backend:/app/tools /tmp/app-tools` and confirmed
|
||||
**byte-identical** (`sha256`) to the repo copy
|
||||
`fifa17-recon/tools/utas_server.py` (both 3765 lines) and
|
||||
`fifa17-recon/tools/fut_clubitems.py`. All line references below are to the repo
|
||||
paths and equal the running code.
|
||||
|
||||
### Commands (exact)
|
||||
```
|
||||
docker ps --format '{{.Names}}\t{{.Image}}\t{{.Command}}\t{{.Status}}'
|
||||
docker inspect openfut-fut-backend --format '...CMD/ENTRYPOINT/MOUNTS/ENV...'
|
||||
docker exec openfut-fut-backend cat /app/entrypoint.sh
|
||||
docker cp openfut-fut-backend:/app/tools /tmp/app-tools
|
||||
docker cp openfut-fut-backend:/app/data /tmp/app-data
|
||||
diff /tmp/app-tools/utas_server.py fifa17-recon/tools/utas_server.py # identical
|
||||
diff /tmp/app-tools/fut_clubitems.py fifa17-recon/tools/fut_clubitems.py # identical
|
||||
```
|
||||
|
||||
### Manual test sequence
|
||||
NOT executed. The manual FIFA-client store capture (2D/2E) was never reached
|
||||
because the investigation blocked at 2C before any flag could be enabled. No
|
||||
request was synthesized, replayed, or simulated.
|
||||
|
||||
---
|
||||
|
||||
## 2. Store handler code path (2A) — OBSERVED
|
||||
|
||||
Request → response for the store tile screen:
|
||||
|
||||
1. **Endpoint.** `GET ut/<sku>/store/purchasegroup/...` — `FutStoreGetPackTypes`
|
||||
(client deser root `0x1801234e0`). (`utas_server.py:3408-3409` docstring.)
|
||||
- Route table entry: `(re.compile(r"/store/purchasegroup"), lambda m,h:
|
||||
store_catalog(h))` at `utas_server.py:1215`.
|
||||
- Sibling store routes: `/store/transaction -> store_buy(h)` (`:1216`, the BUY);
|
||||
bare `/store(\?|$) -> (200,{"result":"SUCCESS"})` eligibility gate (`:1221`).
|
||||
2. **Handler.** `store_catalog(h)` (`utas_server.py:3408-3447`).
|
||||
- Iterates `PACK_CATALOG` (non-`ownedOnly` packs) → `_pack_body(p, idx)`.
|
||||
- Appends owned unopened packs from `visible_unopened_packs()` →
|
||||
`STORE.unopened_packs()` + `_OPENED_PACK_GRACE` (`:51-52`, `:3423-3427`).
|
||||
- If no owned packs, appends one inactive `mypacks` sentinel (id 65534) so
|
||||
`GOTO_STORE_MYPACK` resolves (`:3428-3446`).
|
||||
- Returns `200, {"purchase": [<pack bodies>], "timestamp": 1596326400}`
|
||||
(`:3447`).
|
||||
3. **Definition construction.** `_pack_body(p, idx, owned=False)`
|
||||
(`utas_server.py:3268-3405`). Emitted keys (OBSERVED, `:3293-3328`):
|
||||
`assetId`(=`p["id"]`), `id`(=`p["id"]`), `packType`, `description`(=`p["name"]`),
|
||||
`state`, `saleType`, `limitType`, `quantity`, `purchaseLimit`, `purchaseCount`,
|
||||
`isPremium`, `sortPriority`, `currencies`, `extPrice`, `packContentInfo`
|
||||
(`bronze/silver/gold/rare/itemQuantity`), `unopened`, and one of:
|
||||
- owned pack → `displayGroup = {"value":"mypacks","priority":idx}` (`:3336`);
|
||||
- else if `STORE_DISPLAYGROUP` → `displayGroup = {"value": category}` where
|
||||
`category ∈ {special, gold, silver, bronze}` chosen from
|
||||
`p["specialChance"]`/`p["gold"]` (`:3337`,`:3373-3379`), and if `STORE_GROUPID`
|
||||
also `displayGroupAssetId = p["id"]` (`:3403-3404`).
|
||||
4. **Data source(s).**
|
||||
- `PACK_CATALOG` — a hardcoded list of 3 pack dicts
|
||||
(`{id,name,price,count,gold,tiers,specialChance}`) at `fut_store.py:820-841`.
|
||||
There is NO card table read in this path.
|
||||
- `STORE = Store()` (`fut_store.py:843`), backed by the profile JSON
|
||||
(`unopened_packs()` reads `unopenedPackIds`, `fut_store.py:619-621`).
|
||||
5. **Serialization → HTTP.** The dict is JSON-encoded by the server's response
|
||||
writer and returned as the HTTP body.
|
||||
|
||||
### Fields 5 (`cardsubtypeid`/`carddbid`/`cardassetid`) — OBSERVED
|
||||
**None of `cardsubtypeid`, `carddbid`, or `cardassetid` appear anywhere in the
|
||||
store-tile (`purchasegroup`) response.** `_pack_body` (`:3293-3405`) emits only the
|
||||
pack keys listed above; `assetId`/`id` are the PACK id `p["id"]` (e.g. 1, 5), not a
|
||||
card asset id. The store catalog emits PACKS, never card definitions. (Grep of
|
||||
`_pack_body` and `store_catalog` for those three field names returns zero hits.)
|
||||
|
||||
### Families the store handler can emit
|
||||
Only **packs** (`PACK_CATALOG`: "Bronze Pack" id 1, "Gold Pack" id 5, and the third
|
||||
catalog entry) plus owned reward packs and the `mypacks` sentinel. It cannot emit
|
||||
any card family (players, staff, consumables, club items).
|
||||
|
||||
### Filtering / transformation
|
||||
- `normal = [p for p in PACK_CATALOG if not p.get("ownedOnly")]` (`:3421`).
|
||||
- `_pack_body` maps a pack to a category token via `specialChance>=1.0 -> special`,
|
||||
else `gold -> gold`, `p.get("silver") -> silver`, else `bronze` (`:3373-3379`).
|
||||
- The tile caption/category is `displayGroup.value`; `description` carries the
|
||||
per-pack title.
|
||||
|
||||
---
|
||||
|
||||
## 3. Wire subtype coverage (2B)
|
||||
|
||||
### 3.1 Store path
|
||||
The store-tile path emits **no `cardsubtypeid`** at all (see §2). So for the store
|
||||
tile, "is `cardsubtypeid` the raw FCC subtype, the wire category, transformed, or
|
||||
something else?" → **not present** (N/A). The store tile is selected by
|
||||
`displayGroup.value` (a category-token STRING), not by any card subtype.
|
||||
|
||||
Per `_pack_body:3367-3372` (citing `FUN_180014580`/`FUN_180014df0`): FIFA 17's
|
||||
StoreFront resolves exactly **six hard-coded category tokens** —
|
||||
`mypacks, points, bronze, silver, gold, special`. Any other `displayGroup.value`
|
||||
(e.g. a raw pack title) creates an "unsupported pseudo-category". The documented
|
||||
cause of "unknown" store tiles is therefore a **`displayGroup` category-token**
|
||||
issue on packs (absent group, or a non-canonical token), NOT a card type/subtype.
|
||||
|
||||
### 3.2 Club-item path (the only place wire subtypes live) — `fut_clubitems.py`
|
||||
Club items are served on the **`/club?type=` route** (`utas_server.py:2250`,
|
||||
`handle_club`), gated by `FUT_CLUBITEMS`, NOT by the store route. Current `FAMILIES`
|
||||
(`fut_clubitems.py:61-67`), format `(family, table, art id, stat id, stat name,
|
||||
UNVERIFIED cardsubtypeid)`:
|
||||
|
||||
| wire subtype | mapped family | table | art id | source | confidence |
|
||||
|---|---|---|---|---|---|
|
||||
| 9 | kits | fcc_kitcards.json | 35 | `fut_clubitems.py:65` | HYPOTHESIS (marked "UNVERIFIED", `:49`,`:30-32`) |
|
||||
| 10 | stadia | fcc_stadium.json | 36 | `fut_clubitems.py:63` | HYPOTHESIS ("UNVERIFIED") |
|
||||
| 11 | badges | fcc_badgecards.json| 39 | `fut_clubitems.py:64` | HYPOTHESIS ("UNVERIFIED") |
|
||||
| 30 | balls | fcc_balls.json | 37 | `fut_clubitems.py:62` | HYPOTHESIS ("UNVERIFIED") |
|
||||
| 31 | leaguelogos | fcc_leaguelogos.json| 40| `fut_clubitems.py:66` | HYPOTHESIS ("UNVERIFIED") |
|
||||
|
||||
- The prior recorded mapping (9=kits,10=stadia,11=badges,30=balls,31=logos) is
|
||||
**confirmed present in current code** — but the code itself marks every one
|
||||
"UNVERIFIED cardsubtypeid" and states the binary assigns family↔subtype **nowhere**
|
||||
in the 149 dumped tables; cardtype-9 admits the set `{30,31,145,146,147,148,149,150}`
|
||||
(`fut_clubitems.py:26-28`, `:73`). So these are server-chosen HYPOTHESIS values.
|
||||
- In the club-item wire item (`_item:88-119`), `cardsubtypeid` (`:97`) is a
|
||||
**server-assigned wire-category constant** (9/10/11/30/31), NOT the raw FCC
|
||||
subtype: the club-item fcc tables carry **no `cardsubtype` column at all** (Task 1:
|
||||
badges/stadium/kit/logos/balls have empty subtype sets). `resourceId`/`assetId`
|
||||
= `carddbid`, and `cardassetid` = the family ART id (`:94-96`). So for club items:
|
||||
**`cardsubtypeid` = wire category (server constant), `carddbid`/`cardassetid` =
|
||||
real fcc columns.**
|
||||
- By contrast, consumables (`fut_store._item` / `fut_consumables`) DO carry the raw
|
||||
FCC subtype (51..341) as `cardsubtypeid`.
|
||||
|
||||
### 3.3 Task-1 families vs wire-subtype coverage
|
||||
Families that the **store handler** can map to a wire subtype: **none** — the store
|
||||
handler emits packs, which have no card subtype (by design).
|
||||
|
||||
Families for which a **club-item** wire subtype exists (HYPOTHESIS-grade):
|
||||
kits(9), stadia(10), badges(11), balls(30), leaguelogos(31).
|
||||
|
||||
Task-1 card families with **no wire subtype mapping anywhere in the server**:
|
||||
- **5001xxx contracts, 5002xxx fitness/healing, 5003xxx training** — served as
|
||||
consumables carrying their raw FCC subtype; these are consumable overlays, not
|
||||
store tiles, and not part of any store/club-item wire-category map.
|
||||
- **5004xxx misc {231,232,233,236}** — **no wire subtype mapping found** in
|
||||
`fut_clubitems.py` or the store path. (Grep: no `misc` family, no {231,232,233,236}
|
||||
wire assignment.) They exist only as raw FCC subtypes in consumable data.
|
||||
- **8010xxx league logos/stickers** — mapped in the **club-item** path as wire
|
||||
subtype **31** (`fut_clubitems.py:66`), HYPOTHESIS-grade. `fcc_leaguelogostickers`
|
||||
(39 rows) is NOT wired in `FAMILIES` (only `fcc_leaguelogos`, 44 rows).
|
||||
- **6000xxx badges, 6200xxx stadiums, 6300/6400xxx kits, 8120xxx balls** — mapped in
|
||||
the club-item path (11/10/9/30), HYPOTHESIS-grade.
|
||||
- **staff (managers/coaches, subtypes 4-8)** — served by `fut_staff` on `/club?type=
|
||||
manager`, not a store tile.
|
||||
|
||||
Note none of these belong to the **store-tile** (`purchasegroup`) response.
|
||||
|
||||
---
|
||||
|
||||
## 2C flag investigation — BLOCKED
|
||||
|
||||
### Store-tiles flags located (OBSERVED)
|
||||
Two, both in `utas_server.py`, both module-level constants read **once at import**:
|
||||
|
||||
| flag | env var | line (read) | consumed | default | current running value |
|
||||
|---|---|---|---|---|---|
|
||||
| `STORE_DISPLAYGROUP` | `FUT_STORE_DISPLAYGROUP` | `:975` | `_pack_body:3337` | `"1"` → **ON** | **ON** (no env override) |
|
||||
| `STORE_GROUPID` | `FUT_STORE_GROUPID` | `:980` | `_pack_body:3403` | `"0"` → **OFF** | **OFF** (no env override) |
|
||||
|
||||
- `:975` `STORE_DISPLAYGROUP = os.environ.get("FUT_STORE_DISPLAYGROUP", "1") == "1"`
|
||||
- `:980` `STORE_GROUPID = os.environ.get("FUT_STORE_GROUPID", "0") == "1"`
|
||||
|
||||
**Current values (recorded before any change; nothing was changed):**
|
||||
- `FUT_STORE_DISPLAYGROUP`: unset in container env → default `"1"` →
|
||||
`STORE_DISPLAYGROUP = True` (**ON**). (INFERRED from OBSERVED env dump + OBSERVED
|
||||
code default.)
|
||||
- `FUT_STORE_GROUPID`: unset in container env → default `"0"` →
|
||||
`STORE_GROUPID = False` (**OFF**). This is the flag "expected to be OFF".
|
||||
|
||||
The related club-item flag `FUT_CLUBITEMS` (`:1647-1648`) is likewise unset →
|
||||
`CLUBITEMS = False` (club items not currently served), also a module-level import
|
||||
constant.
|
||||
|
||||
### Dynamic evaluation? NO (OBSERVED)
|
||||
- All three flags are top-level `os.environ.get(...)` assignments evaluated at
|
||||
module import (`:975`, `:980`, `:1647`); `_pack_body` reads the resulting module
|
||||
**constants** (`:3337`, `:3403`), never `os.environ` at request time.
|
||||
- Repo-wide there is **no** `importlib.reload`, no `signal`/`SIGHUP` handler, and no
|
||||
per-request environ re-read for these flags. (The only runtime-refreshable feature
|
||||
is `FUT_ID_SWEEP`, which re-reads a *file* `SWEEP_FILE`, `:1965-1966` — unrelated.)
|
||||
- `:3250` documents the intended workflow explicitly:
|
||||
`# Enable for the test with: FUT_PRICE_PROBE=1 ./openfut-fut.sh restart`.
|
||||
|
||||
### Restart conflict → STOP
|
||||
Changing either store flag requires either setting a container env var (→
|
||||
`docker` recreate = restart) or editing the module (→ re-import = restart). Both
|
||||
violate the no-restart / no-redeploy / no-recreate constraint. Therefore:
|
||||
|
||||
```
|
||||
TASK 2 BLOCKED AT 2C:
|
||||
Flag requires restart, conflicting with no-restart constraint.
|
||||
```
|
||||
|
||||
No flag was enabled. 2D/2E (manual client capture + definition analysis) NOT
|
||||
started. No client request generated, replayed, or simulated.
|
||||
|
||||
---
|
||||
|
||||
## 4. Captured request and response (Phase 3) — OBSERVED
|
||||
|
||||
Real client action (operator opened the FUT Store on `.105`, 2026-08-12). Only one
|
||||
`/store/purchasegroup` request occurred after the capture marker
|
||||
`2026-08-12T23:54:01Z`, so attribution is unambiguous (only the operator drives the
|
||||
client). Log via `docker logs --since 2026-08-12T23:54:01Z --timestamps openfut-fut-backend`.
|
||||
|
||||
- **Request:** `23:54:43 GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true`
|
||||
(Host `10.10.0.120:8099`, `User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)`,
|
||||
`X-UT-SID` present). Preceded at 23:54:43 by `GET /ut/game/fifa17/user/credits`
|
||||
→ `200 {"credits": 29876776, ...}`.
|
||||
- **Response:** `200`. The server log truncates the body to 200 bytes
|
||||
(`utas_server.py:3754`, `raw[:200]`), genuine prefix:
|
||||
`{"purchase": [{"assetId": 1, "id": 1, "packType": "BRONZE", "description": "Bronze Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount`
|
||||
- **Full body recovered** by running the exact running-container code
|
||||
(`docker exec openfut-fut-backend python3 -c "import utas_server as u; u.store_catalog(None)"`)
|
||||
over the live `/state` profile, under the live flags (confirmed in-process:
|
||||
`STORE_DISPLAYGROUP=True`, `STORE_GROUPID=False`). Its `json.dumps(...)[:200]`
|
||||
equals the genuine logged 200-byte prefix **byte-for-byte** (verified MATCH), so the
|
||||
reconstruction IS the sent body (deterministic pure function + verified prefix). No
|
||||
request was synthesized, replayed, or curl'd. Full body (2841 bytes) preserved
|
||||
verbatim at `docs/evidence/store_purchasegroup_capture_2026-08-12.json`.
|
||||
- **Operator-reported client behavior:** on opening the store, an error dialog
|
||||
appeared — *"The pack you've selected is currently not available. Please select a
|
||||
different pack or try again later."* — and clicking OK returned to the FUT hub. No
|
||||
tiles were browsable; **no "unknown" tiles were reported**. There was **no**
|
||||
`PUT /store/transaction` (no buy) and **no server error** (server answered `200`).
|
||||
|
||||
## 5. Definition analysis (Phase 4/5) — OBSERVED
|
||||
|
||||
`purchase[]` = 5 entries (`timestamp` 1596326400). No `cardsubtypeid`/`carddbid`/
|
||||
`cardassetid` in any entry (packs, not cards).
|
||||
|
||||
| idx | id | assetId | packType | description | displayGroup.value | priority | state | dgAssetId | classification |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
| 0 | 1 | 1 | BRONZE | Bronze Pack | `bronze` | — | active | absent | KNOWN TOKEN |
|
||||
| 1 | 5 | 5 | GOLD | Gold Pack | `gold` | — | active | absent | KNOWN TOKEN |
|
||||
| 2 | 6 | 6 | GOLD | Premium Gold | `gold` | — | active | absent | KNOWN TOKEN |
|
||||
| 3 | 7 | 7 | GOLD | Special Players Pack | `special` | — | active | absent | KNOWN TOKEN |
|
||||
| 4 | 65534 | 65534 | GOLD | "" (empty) | `mypacks` | 1 | **inactive** | absent | KNOWN TOKEN |
|
||||
|
||||
Per-pack fields (idx 0-3 identical shape): `saleType:"promo"`, `limitType:"NONE"`,
|
||||
`quantity:0`, `purchaseLimit:0`, `purchaseCount:0`, `isPremium:false`,
|
||||
`currencies:[{"name":"coins","funds":<price>,"finalFunds":<price>}]`,
|
||||
`extPrice:{finalPrice/originalPrice:{amount:<price/100>,currency:"mtx"}}`,
|
||||
`packContentInfo:{...tier quantities...}`, `unopened:false`. Prices: Bronze 400,
|
||||
Gold 5000, Premium Gold 15000, Special Players 25000. The sentinel (idx 4) drops
|
||||
`currencies`/`extPrice`, has empty description, `state:"inactive"`.
|
||||
|
||||
**Unique `displayGroup.value` set emitted: `{bronze, gold, special, mypacks}`.**
|
||||
Compared to the six client renderer tokens `{mypacks, points, bronze, silver, gold,
|
||||
special}` (`FUN_180014580`/`FUN_180014df0`, `plan-2026-08-05-store-subsystem.md:202-206`):
|
||||
**every emitted token is a KNOWN token; the set outside the renderer categories is
|
||||
EMPTY.**
|
||||
|
||||
## 6. Unmapped definitions / suspect tokens found — OBSERVED
|
||||
|
||||
None. Zero SUSPECT/UNKNOWN `displayGroup.value` tokens; zero card definitions;
|
||||
zero `cardsubtypeid`/`carddbid`/`cardassetid`. The only anomalous element is the
|
||||
**inactive, empty-description `mypacks` sentinel** (idx 4), emitted by
|
||||
`store_catalog:3428-3446` **only when the profile owns zero unopened packs**
|
||||
(OBSERVED: profile `unopenedPackIds == []`).
|
||||
|
||||
## 7. Client-side evidence (Phase 7) — existing RE only
|
||||
|
||||
No new Ghidra run. Existing decompiler evidence already bounds the answer and shows
|
||||
new static analysis would be unproductive:
|
||||
- **The parser is not the gate** (`OPENCODE_ENDPOINT_PROMPT.md:118-120`): per-pack
|
||||
epilogue `0x18013badc` pushes every parsed pack unconditionally — no drop predicate
|
||||
in the parse path. So a `200` with clean JSON cannot be rejected by the deserializer.
|
||||
- **Store-level "not available"** (`FUT_CatalogNotAvailable`, msg `0x7550`) comes from
|
||||
downstream client gates (`OPENCODE_ENDPOINT_PROMPT.md:120-131`): (1) resolution
|
||||
`GetSystemMetrics` ≤1024×768, (2) store-data-model load status `0x180013cf0`,
|
||||
(3) Blaze purchase-config flags `IS_STORE_ENABLED/IS_COIN_PURCHASABLE/...` (these
|
||||
are already served, `blaze_responder_v3b.py:708-719`).
|
||||
- **Per-pack tile-availability predicate is in the PACKED FIFA17.exe and UNREAD**
|
||||
(`plan-2026-08-05-pack-opening.md:553`): fields `state/saleType/quantity/
|
||||
purchaseLimit/purchaseCount/start/end` are parsed and copied to the tile, but the
|
||||
predicate that greys/blocks a tile "is in the packed exe and unread." Whether
|
||||
`purchaseLimit`+`purchaseCount` greys a tile is explicitly an OPEN question
|
||||
(`:644-645`). Static Ghidra on the packed exe cannot read it (decrypts only in live
|
||||
memory); resolving it requires a live-memory experiment on the running FIFA process,
|
||||
which is out of scope (must not touch FIFA).
|
||||
- The exact operator string *"The pack you've selected is currently not available"*
|
||||
is **not present** anywhere in the recon corpus (docs/tools); the documented
|
||||
store/pack error strings are `FUT_CatalogNotAvailable` and
|
||||
`CARDS_CB_ERR_PACK_NOT_IN_DIME` (a server-returnable code). UNKNOWN loc key.
|
||||
|
||||
## 8. Assessment (Phase 8) — runtime verdict
|
||||
|
||||
- **H1 (subtype/definition): CONTRADICTED.** The captured response contains no card
|
||||
definitions and no `cardsubtypeid`/`carddbid`/`cardassetid` (OBSERVED §4-§5).
|
||||
- **H2 (unknown `displayGroup.value` token): CONTRADICTED.** Every emitted token is a
|
||||
KNOWN renderer category (`bronze/gold/special/mypacks`); the unsupported-token set
|
||||
is EMPTY (OBSERVED §5). The store-tile "unknown" mechanism is NOT reproduced under
|
||||
the current config (`STORE_DISPLAYGROUP=ON`).
|
||||
|
||||
Answering the Phase-8 questions:
|
||||
1. **Unsupported `displayGroup.value` in the response?** No — all four tokens
|
||||
(`bronze/gold/special/mypacks`) are recognized. (OBSERVED)
|
||||
2. **Which pack got it?** None. (OBSERVED)
|
||||
3. **Correspond to an unknown tile in FIFA?** No unknown tile was reported; the
|
||||
observed symptom was a *"pack not available"* dialog, not an unknown tile. (OBSERVED)
|
||||
4. **Where does the backend assign the token?** `_pack_body:3373-3379` maps each pack
|
||||
to `special/gold/silver/bronze` from `specialChance`/`gold`; owned/sentinel →
|
||||
`mypacks` (`:3336`). All canonical. (OBSERVED)
|
||||
5. **Is `displayGroup.value` sufficient to explain the bug?** No. The response is
|
||||
clean; the failure is a downstream client/packed-exe gate, not a token. (INFERRED)
|
||||
6. **Is the server emitting a value FIFA demonstrably cannot understand?** No.
|
||||
(OBSERVED)
|
||||
7. **Narrowest likely fix (REPORT ONLY — not implemented):** The single anomalous,
|
||||
server-controllable element is the **inactive empty `mypacks` sentinel** emitted
|
||||
when `unopenedPackIds == []` (`store_catalog:3428-3446`). Leading HYPOTHESIS: with
|
||||
no owned packs, the store's My-Packs group contains only this inactive pack, and
|
||||
the client's (packed-exe) selection/availability path lands on it →
|
||||
*"the pack you've selected is currently not available"* → back to hub. Narrowest
|
||||
candidate fixes to TEST (each needs a controlled change, hence a future
|
||||
restart-gated experiment — do NOT implement now):
|
||||
(a) suppress the sentinel when there are no owned packs and instead let the store
|
||||
land on a real active category (bronze/gold/special); or
|
||||
(b) if My-Packs must resolve, make the sentinel non-selectable rather than an
|
||||
`inactive` pack in the group.
|
||||
Cheaper-to-eliminate CLIENT-side cause to check first (existing RE, no server
|
||||
change): FIFA display resolution must be **>1024×768** on `.105`
|
||||
(`OPENCODE_ENDPOINT_PROMPT.md:122-124`).
|
||||
|
||||
**Overall runtime verdict: CONTRADICTED** — neither H1 nor H2 reproduces; the
|
||||
"unknown store tile" hypothesis is not the live failure. The live failure is a
|
||||
distinct *pack-availability* error whose trigger is in the packed FIFA17.exe and
|
||||
cannot be pinned from the (clean) server response alone.
|
||||
|
||||
## 9. Open questions
|
||||
1. What exactly raises *"The pack you've selected is currently not available"*? The
|
||||
loc key is unknown and the predicate is in the packed exe (unread). Resolving it
|
||||
needs a controlled field/flag experiment or live-memory RE (both currently gated).
|
||||
2. Does the store, with `unopenedPackIds == []`, land on / auto-select the inactive
|
||||
`mypacks` sentinel? (HYPOTHESIS §8.7; unproven without client-side observation.)
|
||||
3. Did the store render tiles successfully in earlier sessions when the profile
|
||||
owned unopened packs (e.g. the 21:54-21:57 pack-opening burst)? If so, the
|
||||
presence/absence of owned packs (sentinel) is implicated. (UNKNOWN — earlier logs
|
||||
truncate the body; not proven.)
|
||||
4. Does `purchaseLimit:0`/`purchaseCount:0` grey a tile? Existing RE lists this as an
|
||||
OPEN question; would need a controlled experiment. (UNKNOWN)
|
||||
5. Is bug 6c ("unknown tile") a stale symptom from before `STORE_DISPLAYGROUP` became
|
||||
the default `ON`? Under the current config no unknown tile reproduces.
|
||||
|
||||
---
|
||||
|
||||
## H3 — EMPTY MY-PACKS SENTINEL (HYPOTHESIS)
|
||||
|
||||
When the profile owns zero unopened packs (`unopenedPackIds == []`), OpenFUT emits
|
||||
synthetic **inactive** pack id **65534** in the `mypacks` display group
|
||||
(`store_catalog:3428-3446`). The FIFA 17 store may treat this object as a selectable
|
||||
pack whose availability predicate fails, producing *"The pack you've selected is
|
||||
currently not available"* and returning the user to the FUT Hub before any
|
||||
`/store/transaction`. **Status: HYPOTHESIS (untested).**
|
||||
|
||||
## 10. Resolution check (Phase 1) — OBSERVED
|
||||
|
||||
Read-only inspection of `.105` (FIFA pid 529227, not touched):
|
||||
- Desktop/monitor: **2560x1440** — DRM connectors `card1-DP-2` and `card1-HDMI-A-1`
|
||||
both `connected`, native mode 2560x1440; compositor KDE `kwin_wayland` (no gamescope).
|
||||
- FIFA render config: `…/Games/umu/fifa17/pfx/drive_c/users/steamuser/Documents/FIFA 17/settings/overrideAutodetect.lua`
|
||||
→ `ResolutionWidth = 1280`, `ResolutionHeight = 720`, `FullscreenEnabled = 0` (windowed).
|
||||
- Session: Wayland (`WAYLAND_DISPLAY=wayland-0`), FIFA via XWayland (`DISPLAY=:0`,
|
||||
`XAUTHORITY=/run/pressure-vessel/Xauthority` inside the game namespace).
|
||||
|
||||
**Is FIFA rendering above 1024x768? YES (1280x720).**
|
||||
**Resolution hypothesis eliminated for this reproduction.**
|
||||
|
||||
## 11. Sentinel 65534 provenance (Phase 3)
|
||||
|
||||
1. **Basis:** **OpenFUT INVENTION** (OBSERVED). `65534` (0xFFFE) appears nowhere in
|
||||
any EA capture or recon note — repo-wide it exists only in `utas_server.py`
|
||||
(`store_catalog:3435-3446`) and this evidence set. The author's comment
|
||||
(`:3428-3434`) states it is a workaround: "Retain an inactive zero-item sentinel so
|
||||
the [`mypacks`] destination resolves… Its id is deliberately absent from
|
||||
PACK_CATALOG." It is a compatibility guess, not captured behavior.
|
||||
2. **Known-good EA capture of EMPTY My Packs:** **UNKNOWN** — none found. All recon
|
||||
My-Packs analysis is client-side RE (`FUN_1800150d0` filters
|
||||
`displayGroup.value=="mypacks"`, `plan-2026-08-05-pack-opening.md:34-36,893-895`);
|
||||
no EA server response for an empty My Packs state is on record.
|
||||
3. **Known-good response with ≥1 unopened pack:** **UNKNOWN** for EA. OpenFUT's own
|
||||
seed grants reward pack id 70 (`_new_profile` `unopenedPackIds:[70]`,
|
||||
`fut_store.py:360`), but that is an OpenFUT synthetic grant, not an EA capture.
|
||||
4. **Evidence FIFA EXPECTS a sentinel/placeholder:** **NO / UNKNOWN.** Recon shows My
|
||||
Packs is a client-side filter over the ordinary catalogue; the "empty-category
|
||||
dialog over the wrong tab" concern behind the sentinel is the author's HYPOTHESIS,
|
||||
not decompiler-confirmed. No evidence FIFA requires a placeholder object.
|
||||
5. **Evidence for the `inactive` representation:** **UNKNOWN / HYPOTHESIS.** The claim
|
||||
"state != active keeps it out of the visible row list" (`:3432`) is an unverified
|
||||
author assumption; no RE shows `state:"inactive"` hides a pack from selection. If
|
||||
FIFA does NOT hide it, the sole `mypacks` entry is a selectable inactive pack —
|
||||
exactly the H3 failure mode.
|
||||
|
||||
## 12. Empty vs non-empty My Packs behavior (Phase 2) — OBSERVED (code) / captured
|
||||
|
||||
`store_catalog(h)` (`utas_server.py:3421-3447`):
|
||||
- Always emits the 4 non-`ownedOnly` catalogue packs (ids 1,5,6,7) via `_pack_body`.
|
||||
- For each id in `visible_unopened_packs()` (= `STORE.unopened_packs()` +
|
||||
`_OPENED_PACK_GRACE`) that resolves in `PACK_CATALOG`, appends `_pack_body(owned,
|
||||
idx, owned=True)`.
|
||||
- **Only when `unopened_packs()` is empty (`if not owned_ids`)** appends the inactive
|
||||
sentinel (`:3428-3446`).
|
||||
|
||||
Sentinel 65534 vs a normal active pack (Bronze, idx 0), field-by-field (from the
|
||||
captured body):
|
||||
|
||||
| field | sentinel 65534 | Bronze Pack (active) |
|
||||
|---|---|---|
|
||||
| id | 65534 | 1 |
|
||||
| assetId | 65534 | 1 |
|
||||
| packType | GOLD | BRONZE |
|
||||
| description | "" (empty) | "Bronze Pack" |
|
||||
| state | **inactive** | active |
|
||||
| saleType | promo | promo |
|
||||
| limitType | NONE | NONE |
|
||||
| quantity | 0 | 0 |
|
||||
| purchaseLimit | 0 | 0 |
|
||||
| purchaseCount | 0 | 0 |
|
||||
| isPremium | false | false |
|
||||
| sortPriority | 1 | 1 |
|
||||
| currencies | **ABSENT** (popped) | `[{coins,400,400}]` |
|
||||
| extPrice | **ABSENT** (popped) | `{mtx 4/4}` |
|
||||
| packContentInfo | all-zero quantities | bronze 5 / item 5 |
|
||||
| unopened | false | false |
|
||||
| displayGroup.value | **mypacks** | bronze |
|
||||
| displayGroup.priority | 1 | ABSENT |
|
||||
| displayGroupAssetId | ABSENT | ABSENT |
|
||||
|
||||
**What changes when `unopenedPackIds` is non-empty (e.g. `[70]`):** the sentinel is
|
||||
NOT emitted; instead pack 70 (Reward Special Players Pack, `ownedOnly`) appears via
|
||||
the owned branch of `_pack_body` (`:3335-3336`): `displayGroup={"value":"mypacks",
|
||||
"priority":idx}`, `state:"active"` (default), `unopened:true`, `currencies`/`extPrice`
|
||||
popped. i.e. the `mypacks` group would hold a genuine **active** owned pack instead of
|
||||
the inactive sentinel.
|
||||
|
||||
## 13. Proposed controlled experiments (Phase 5) — DESIGN ONLY, NOT EXECUTED
|
||||
|
||||
### Existing grant mechanism (Phase 4)
|
||||
- **Supported profile-only method: YES** — `Store.grant_unopened_pack(pack_id)`
|
||||
(`fut_store.py:635-643`): validates the id is in `PACK_CATALOG`, appends to
|
||||
`unopenedPackIds`, persists; reverts via `consume_unopened_pack` (`:623-633`).
|
||||
Modifies **only** profile state; cleanly reversible.
|
||||
- **BUT restart IS required to take effect.** `Store.load()` caches `self._p`
|
||||
(`:370-372`); **no route calls `grant_unopened_pack` or `select_account`**, and
|
||||
`select_account` only re-reads on a persona *change*. So an out-of-process grant or
|
||||
a raw profile-file edit writes disk but the **running server keeps serving its
|
||||
cached `unopenedPackIds`** until the process reloads. There is no SIGHUP/reload
|
||||
endpoint. Therefore any profile change needs a container restart to be observed.
|
||||
|
||||
### Experiment A — Non-empty My Packs (PREFERRED; least invasive)
|
||||
- **State change:** set the profile's `unopenedPackIds` to `[70]` (edit
|
||||
`…/state/accounts/33068179/fifa17_profile.json`, or call
|
||||
`STORE.grant_unopened_pack(70)`), **store code/config unchanged**.
|
||||
- **Restart required?** **YES** — profile cache (above). Profile-only; no code edit.
|
||||
- **Rollback:** set `unopenedPackIds` back to `[]` (or `consume_unopened_pack(70)`),
|
||||
restart. (Also restore `nextItemId`/coins only if a pack is actually opened — the
|
||||
grant alone touches only `unopenedPackIds`.)
|
||||
- **Expected `purchasegroup` difference:** sentinel 65534 GONE; instead one active
|
||||
pack id 70 in the `mypacks` group (`state:active`, `unopened:true`); hub/credits
|
||||
report `recoveredPacks:1`.
|
||||
- **Expected client observation:** if H3 is correct, the immediate *"pack not
|
||||
available"* dialog should NOT fire (or behavior changes) and My Packs should show a
|
||||
real pack. If the dialog still fires identically, H3 is weakened and the cause is
|
||||
elsewhere (packed-exe predicate / another field).
|
||||
|
||||
### Experiment B — Suppress the empty sentinel (only if A is inconclusive)
|
||||
- **Change:** in `store_catalog` (`:3428-3446`), when `unopened_packs()` is empty, do
|
||||
NOT append pack 65534 (emit no `mypacks` entry). **Code change ⇒ restart. NOT
|
||||
authorized.** Narrowest patch: guard/remove the `if not owned_ids:` sentinel block.
|
||||
- **Purpose:** distinguishes "the inactive sentinel is selected and fails" (A already
|
||||
tests the inverse) from "an absent `mypacks` group causes a different failure"
|
||||
(the original author's stated fear at `:3429-3431`).
|
||||
|
||||
### Experiment C — Alternate sentinel representation (design only)
|
||||
- If evidence later shows FIFA expects an empty `mypacks` group represented
|
||||
differently (e.g. present-but-not-a-pack, or `state` other than `inactive`), adjust
|
||||
the sentinel shape. **DESIGN ONLY; DO NOT IMPLEMENT.** No current evidence specifies
|
||||
the correct empty-group representation (see §11.4-11.5).
|
||||
|
||||
Note: both A and B require a restart (A for the profile cache, B for the code). A is
|
||||
strictly less invasive (profile-only, clean rollback, no code change) and is the
|
||||
preferred next experiment. Neither is authorized yet.
|
||||
|
||||
---
|
||||
|
||||
## 14. Experiment A — Non-empty My Packs (EXECUTED 2026-08-13) — OBSERVED
|
||||
|
||||
Authorized controlled test of H3: change ONLY the profile's `unopenedPackIds`
|
||||
(`[] → [70]`), restart the FUT backend once, capture a genuine FIFA store request,
|
||||
then roll back. No store code/config/flags/PACK_CATALOG/pack-70/sentinel-code
|
||||
changed; FIFA not modified/restarted; operator drove the client.
|
||||
|
||||
### 14.1 Baseline profile state
|
||||
- Path: `fifa17-recon/docker/state/accounts/33068179/fifa17_profile.json` (persona
|
||||
33068179/CAGE; proven live: `STORE.path` in-process = `/state/accounts/33068179/
|
||||
fifa17_profile.json`, coins 29876776 matching the live `/user/credits`).
|
||||
- `unopenedPackIds == []`. Original SHA-256
|
||||
`39bb3e833fa55287d8516815ba3a717b41c0f0c7a7c41d20503f3a55c65cc6e7`. Backup:
|
||||
`/tmp/fifa17_profile.33068179.ORIG.20260813T001228Z.json` (same hash).
|
||||
|
||||
### 14.2 State change
|
||||
- Narrow anchored edit of line 97070 only: ` "unopenedPackIds": [],` →
|
||||
` "unopenedPackIds": [70],`. Diff vs backup = exactly one line; semantic diff =
|
||||
only key `unopenedPackIds` (`[] → [70]`), all other 24 keys identical. Modified
|
||||
SHA-256 `2b5760baa265f320904de2d23fd6ab374733efe74cb0756c3be39c083bce4ac8`.
|
||||
(Used the direct edit rather than `grant_unopened_pack(70)` to avoid whole-file
|
||||
reserialization; net semantic effect is identical.)
|
||||
|
||||
### 14.3 Restart
|
||||
- `docker restart openfut-fut-backend` (Pid 1398009→1548312, StartedAt
|
||||
2026-08-12T16:40:52Z → 2026-08-13T00:13:54Z). Bridge/Core and Rust hosts untouched.
|
||||
This container bundles blaze/roster/utas/pow (per `entrypoint.sh`); all rebound.
|
||||
- Post-restart in-process check: `STORE.load()['unopenedPackIds'] == [70]`;
|
||||
`store_catalog(None)` → pack 70 present, sentinel 65534 absent.
|
||||
|
||||
### 14.4 Genuine FIFA capture
|
||||
- Marker `2026-08-13T00:14:24Z`. Single request after it (unambiguous):
|
||||
`00:14:58 GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true → 200`.
|
||||
**No `/store/transaction`, no `/purchased/items`** in the window (pack 70 not opened).
|
||||
- Full body recovered from the running code over the live [70] profile; its
|
||||
`[:200]` matches the genuine logged 200-byte prefix byte-for-byte (verified MATCH).
|
||||
Preserved at `docs/evidence/store_purchasegroup_capture_mypacks70_2026-08-12.json`.
|
||||
|
||||
### 14.5 Client-observed behavior (operator report)
|
||||
1. Store remains open: **YES**.
|
||||
2. "The pack you've selected is currently not available": **NO (gone)**.
|
||||
3. My Packs category / unopened reward pack visible: **YES**.
|
||||
4. Returned to FUT Hub: yes (normal navigation; not forced by an error dialog).
|
||||
|
||||
### 14.6 Response diff (baseline 2026-08-12 vs experiment)
|
||||
Only difference across all 5 entries:
|
||||
- **Removed:** id `65534` (`state:inactive`, `displayGroup:mypacks`, `description:""`).
|
||||
- **Added:** id `70` (`state:active`, `displayGroup:mypacks`,
|
||||
`description:"Reward Special Players Pack"`, `unopened:true`).
|
||||
- Packs 1/5/6/7 byte-identical. Classification: **all EXPECTED FROM UNOPENED PACK
|
||||
STATE; nothing UNEXPECTED.**
|
||||
|
||||
### 14.7 H3 assessment — **SUPPORTED**
|
||||
65534 disappeared AND pack 70 replaced it as a real My Packs entry AND the
|
||||
"pack not available" / store-exit behavior disappeared → per the pre-registered
|
||||
criterion, **H3 is strongly SUPPORTED**. The failure is tied to the My Packs group
|
||||
content when the profile owns zero unopened packs.
|
||||
|
||||
Sub-hypothesis resolution:
|
||||
- **H3c (failure unrelated to My Packs): RULED OUT.** A My-Packs-only profile change
|
||||
(no store code/config change) eliminated the failure.
|
||||
- **H3a (the inactive sentinel object itself is the trigger) vs H3b (empty My Packs
|
||||
state generally is the trigger): NOT DISTINGUISHED by Experiment A.** The change
|
||||
simultaneously (i) removed the inactive sentinel and (ii) supplied a real active
|
||||
owned pack. Either "presence of the inactive/empty sentinel" or "absence of any
|
||||
real owned pack" could be the cause. Distinguishing them requires Experiment B
|
||||
(empty `unopenedPackIds` AND suppress the sentinel so `mypacks` has no entry): if
|
||||
that also fixes it → H3a (sentinel object was the problem); if it re-breaks or
|
||||
changes → H3b (empty My Packs itself is the problem). Experiment B is a code change
|
||||
(restart-gated) and remains unauthorized.
|
||||
|
||||
### 14.8 Rollback verification
|
||||
- Profile restored from backup → SHA-256 `39bb3e83…` == original (byte-identical);
|
||||
`unopenedPackIds == []`. Disk was unmutated during the test (still `2b5760ba…`
|
||||
before rollback → store reads don't persist; pack 70 never opened).
|
||||
- `docker restart openfut-fut-backend` (Pid 1549503, StartedAt 2026-08-13T00:16:56Z).
|
||||
Post-restart in-process: `unopenedPackIds == []`, sentinel 65534 present again,
|
||||
pack 70 absent → runtime baseline restored. Bridge/Core untouched.
|
||||
|
||||
**H3 status: SUPPORTED (H3c ruled out; H3a vs H3b open).** No permanent fix
|
||||
implemented.
|
||||
|
||||
---
|
||||
|
||||
## 15. Experiment B — Empty My Packs Without Sentinel (EXECUTED 2026-08-13) — OBSERVED
|
||||
|
||||
### 15.1 Purpose
|
||||
Distinguish **H3a** (the synthetic inactive sentinel 65534 itself is the trigger)
|
||||
from **H3b** (FIFA cannot tolerate an empty My Packs state even without a sentinel).
|
||||
Hold `unopenedPackIds == []` constant; change ONLY: sentinel 65534 emitted →
|
||||
suppressed. Authorized TEMPORARY code change, reverted after test.
|
||||
|
||||
### 15.2 Baseline
|
||||
Profile `unopenedPackIds == []` (SHA-256 `39bb3e83…`, unchanged throughout). Running
|
||||
code before patch = `c89d43ea…` (host repo == container copy). Sentinel 65534 emitted.
|
||||
|
||||
### 15.3 Temporary patch — **TEMPORARY EXPERIMENT B PATCH, NOT A PERMANENT FIX**
|
||||
Applied to the **container** copy `/app/tools/utas_server.py` only (the container
|
||||
mounts `/state`, not `/app`; host repo `fifa17-recon/tools/utas_server.py` was NOT
|
||||
edited — its git diff stayed empty). Single line, `store_catalog` (line 3428):
|
||||
```
|
||||
- if not owned_ids:
|
||||
+ if False: # TEMP EXPERIMENT B PATCH -- suppress synthetic sentinel 65534 (NOT A PERMANENT FIX)
|
||||
```
|
||||
Semantic effect: when `unopened_packs()` is empty, append nothing (no sentinel, no
|
||||
replacement object). Normal packs 1/5/6/7 (appended earlier) unchanged. Diff vs the
|
||||
backed-up original = exactly this one line. Patched code SHA-256 `5bb8fca9…`.
|
||||
|
||||
### 15.4 Restart verification
|
||||
`docker restart openfut-fut-backend` (Pid 1549503→1551026, StartedAt
|
||||
2026-08-13T00:20:45Z). The writable-layer edit survived the restart; running
|
||||
`/app/tools/utas_server.py` = `5bb8fca9…` (patched). In-process: `unopenedPackIds ==
|
||||
[]`; `store_catalog` → 4 packs {1,5,6,7}, **no 65534, no 70, no `mypacks` entry**.
|
||||
Flags unchanged (`DISPLAYGROUP=ON`, `GROUPID=OFF`). Bridge/Core/Rust untouched.
|
||||
|
||||
### 15.5 Genuine FIFA capture
|
||||
Marker `2026-08-13T00:20:55Z`. Request sequence (operator opened the store):
|
||||
`00:21:05 GET /hub` → `00:21:07 GET /user/credits` → `00:21:07 GET
|
||||
/store/purchasegroup/all?ppInfo=true → 200`. **No `/store/transaction`; no further
|
||||
requests** (client crashed after receiving the store body). Full body recovered from
|
||||
the running patched code; `[:200]` matches the genuine logged prefix byte-for-byte
|
||||
(verified MATCH). Preserved at
|
||||
`docs/evidence/store_purchasegroup_capture_empty_no_sentinel_2026-08-12.json`
|
||||
(4 packs {1,5,6,7}, no `mypacks` group).
|
||||
|
||||
### 15.6 Client behavior (operator report)
|
||||
**The game CRASHED** on opening the store. Not the baseline dialog; a hard crash. No
|
||||
`/store/transaction` was issued.
|
||||
|
||||
### 15.7 Three-way response comparison
|
||||
| capture | ids present | `mypacks` group entry | client outcome |
|
||||
|---|---|---|---|
|
||||
| Baseline (`…_2026-08-12.json`) | 1,5,6,7,**65534** | 65534 `inactive`, desc "" | "pack not available" dialog → Hub |
|
||||
| Exp A (`…_mypacks70_…json`) | 1,5,6,7,**70** | 70 `active`, "Reward Special Players Pack" | **works** — store open, My Packs visible |
|
||||
| Exp B (`…_empty_no_sentinel_…json`) | 1,5,6,7 | **none** | **CRASH** |
|
||||
Normal packs {1,5,6,7} identical across all three.
|
||||
|
||||
### 15.8 H3a / H3b verdict
|
||||
- **H3a (sentinel object itself is the trigger): CONTRADICTED.** Removing the
|
||||
sentinel did NOT restore the store; it produced a *worse* outcome (crash). If the
|
||||
sentinel object were the sole cause, its removal would yield a working store (it
|
||||
did not).
|
||||
- **H3b (FIFA cannot tolerate an empty My Packs state): SUPPORTED.** Only Exp A — a
|
||||
real **active** owned pack in `mypacks` — worked. Both the inactive sentinel
|
||||
(graceful "pack not available" dialog) and the total absence of any `mypacks` entry
|
||||
(crash) fail. The sentinel is a **load-bearing workaround** that *downgrades* the
|
||||
failure from a crash to a dialog but does not fix it.
|
||||
- **Pre-registered-rule nuance:** Exp B produced a *distinct* failure (crash), which
|
||||
the pre-registered rules classify as **B3 (different failure)** rather than the
|
||||
exact B2 dialog. Documented as such: the crash is a THIRD failure mode. It still
|
||||
resolves the question — it rules out H3a and supports H3b — but the specific
|
||||
outcome (crash, not the same dialog) is stronger than B2 anticipated. Not forced
|
||||
into a clean binary beyond what the evidence shows.
|
||||
|
||||
**Does FIFA tolerate an empty My Packs without the sentinel? NO — it crashes.**
|
||||
|
||||
### 15.9 Rollback verification
|
||||
- Container `/app/tools/utas_server.py` restored from backup → SHA-256 `c89d43ea…`
|
||||
== pre-experiment (byte-identical); the `if False:` patch fully removed.
|
||||
- `docker restart openfut-fut-backend` (final Pid 1551901,
|
||||
StartedAt 2026-08-13T00:22:04Z). In-process: `unopenedPackIds == []`, sentinel
|
||||
65534 emitted again, pack 70 absent; `DISPLAYGROUP=ON`, `GROUPID=OFF`.
|
||||
- Host repo `fifa17-recon/tools/utas_server.py` never edited (git diff empty, SHA-256
|
||||
`c89d43ea…`). Profile unchanged (`39bb3e83…`). Bridge/Core/Rust untouched.
|
||||
|
||||
### 15.10 Likely permanent fix (REPORT ONLY — not implemented)
|
||||
Evidence: the store's `mypacks` group must contain a **valid, active, openable owned
|
||||
pack**; both an inactive sentinel and an absent group fail (dialog / crash). The only
|
||||
working configuration observed is a genuine active owned pack (Exp A). Candidate
|
||||
directions (report only, each needs design + authorization):
|
||||
1. Ensure the profile always owns ≥1 legitimate active unopened pack while the store
|
||||
is shown (e.g. keep a real reward pack such as id 70 granted), so `mypacks` is
|
||||
never empty — this matches the only known-working state but changes economy state
|
||||
and needs a lifecycle policy (what happens after the user opens it).
|
||||
2. Change what `store_catalog` advertises so FIFA never lands on / requires a
|
||||
`mypacks` group when there are zero owned packs (client-compatible empty-store
|
||||
representation) — the correct representation is UNKNOWN; neither current option
|
||||
(inactive sentinel / no group) is it, so this needs new client-side RE before
|
||||
implementation.
|
||||
Recommendation: do NOT simply delete the sentinel (Exp B proves that crashes). No fix
|
||||
implemented.
|
||||
|
||||
**H3 status: SUPPORTED. H3a CONTRADICTED, H3b SUPPORTED (Exp B crash = third failure
|
||||
mode; empty My Packs is the root problem). Sentinel is a load-bearing workaround.**
|
||||
|
||||
---
|
||||
|
||||
## 16. Experiment C′ — Active Non-Openable Placeholder (EXECUTED 2026-08-13) — OBSERVED
|
||||
|
||||
Question: can the required `mypacks` group be kept structurally valid with an
|
||||
**active** placeholder that stays impossible to open/purchase? Change exactly one
|
||||
field of sentinel 65534: `state "inactive" → "active"`. Profile untouched
|
||||
(`unopenedPackIds==[]`); 65534 kept absent from `PACK_CATALOG`.
|
||||
|
||||
### 16.1 Server-side safety proof (OBSERVED, code)
|
||||
65534 cannot grant value regardless of `state` — pack resolution is by
|
||||
`pack_by_id(id)` over `PACK_CATALOG` (ids 1,5,6,7,70), independent of the display
|
||||
`state`:
|
||||
- `store_buy` (PUT `/store/transaction`, `:3460-3465`): `pack_by_id(65534)=None` →
|
||||
`if not pack: return 200, {}` (no `open_pack`, no coin change).
|
||||
- `purchased_items` (POST, `:3494-3496`): `pack_by_id(65534)=None` →
|
||||
`return 200, {"itemData": STORE.last_pack()}` (stale prior items only; no new
|
||||
grant, no `open_pack`, no `consume_unopened_pack`).
|
||||
- `open_pack`/`consume_unopened_pack` are unreachable for 65534 (pack resolves to
|
||||
None first). Precondition PASSED.
|
||||
|
||||
### 16.2 Baseline / patch
|
||||
Baseline: profile `39bb3e83…`, code `c89d43ea…` (host==container), sentinel
|
||||
`inactive`/`mypacks`, 65534∉catalog, flags `DISPLAYGROUP=ON`/`GROUPID=OFF`.
|
||||
Temporary container-only patch (host repo untouched), line 3444:
|
||||
`empty["state"] = "inactive"` → `empty["state"] = "active"`. Diff vs original = this
|
||||
one line; patched code `e1a4e1dc…`. **TEMPORARY EXPERIMENT C′ PATCH — NOT A PERMANENT
|
||||
FIX.**
|
||||
|
||||
### 16.3 Restart / runtime
|
||||
`docker restart openfut-fut-backend` (Pid 1556305, StartedAt 00:38:51Z). Running code
|
||||
`e1a4e1dc…`; `unopenedPackIds==[]`; sentinel `65534 state=active unopened=False
|
||||
dg=mypacks desc=""`; 65534∉catalog; normal packs 1/5/6/7 active; 70 absent; flags
|
||||
unchanged.
|
||||
|
||||
### 16.4 Genuine FIFA capture
|
||||
Marker `2026-08-13T00:38:53Z`. FIFA relaunched (Exp-B crash had closed it) → booted to
|
||||
hub. Three genuine store fetches: `00:39:44`, `00:40:26`, `00:41:23`
|
||||
(`GET /store/purchasegroup/all?ppInfo=true → 200`), reconstructed body prefix-matches
|
||||
the logged 200-byte prefix (verified). Saved
|
||||
`docs/evidence/store_purchasegroup_capture_active_placeholder_2026-08-12.json`.
|
||||
C′-vs-baseline full JSON diff = **only** `65534.state: "inactive" → "active"`.
|
||||
|
||||
### 16.5 UI observation (operator report)
|
||||
1. **No crash** (game launched to hub).
|
||||
2. **No "pack not available" dialog.**
|
||||
3. Store remains open.
|
||||
4. My Packs tab **not shown while inside the Store (Browse Packs)**.
|
||||
5. Via the FUT-hub **My Packs** menu: **one pack tile with no cover, "0 items, 0
|
||||
bronze, 0 rares"** (the placeholder renders as a visible empty pack).
|
||||
6. Navigation: from the hub **My Packs** menu → Bronze/Gold/Special reachable; but
|
||||
from the **Browse Packs** (Store) entry, Bronze/Gold/Special are **not reachable
|
||||
until My Packs is opened first**.
|
||||
|
||||
### 16.6 Passive request sequence (OBSERVED)
|
||||
Boot: `.../accountinfo → /ut/auth → settings → phishing → match/reset → userMassInfo
|
||||
→ PUT store/transaction/0 → hub …`. The single `/store/transaction` is the routine
|
||||
**boot** call with body `{"state":"TRANSACTIONCANCEL"}` → `200 {}` (no packId), fired
|
||||
at 00:39:39 **before** any store fetch. Across all three store opens: **no
|
||||
`/store/transaction`, no `/purchased`, and no request referencing 65534.** The active
|
||||
placeholder did NOT cause FIFA to auto-submit any transaction/open.
|
||||
|
||||
### 16.7 Availability result / verdict
|
||||
**Result C1 (strong positive) — ACTIVE PLACEHOLDER HYPOTHESIS SUPPORTED, with UX
|
||||
caveats.** `state` participates materially: with `state:"active"` the group exists
|
||||
(no crash, as in baseline) AND the availability path is satisfied (no
|
||||
"pack not available" dialog, unlike baseline). So **C2 is refuted** — `state` is a
|
||||
deciding field for the dialog. But it is **not a clean permanent fix**:
|
||||
- the placeholder renders as a **visible empty pack tile** ("0 items"), i.e. a fake
|
||||
pack a user could try to open (server-safe: opening → no-op `{}` / stale
|
||||
`last_pack`, but confusing UX);
|
||||
- **navigation caveat #6**: from Browse Packs the other categories are gated behind
|
||||
opening My Packs first — an UNEXPECTED behavior not present with a genuine owned
|
||||
pack (Exp A).
|
||||
|
||||
### 16.8 Rollback verification
|
||||
Container code restored from backup → `c89d43ea…` (== host, == pre-experiment); the
|
||||
`state` change removed. `docker restart` (Pid 1557831, StartedAt 00:43:04Z).
|
||||
In-process: `unopenedPackIds==[]`, sentinel `state=inactive`, 65534∉catalog, 70
|
||||
absent, flags `DISPLAYGROUP=ON`/`GROUPID=OFF`. Host repo `utas_server.py` never edited
|
||||
(`c89d43ea…`, git diff empty). Profile `39bb3e83…` unchanged. Bridge/Core untouched.
|
||||
|
||||
### 16.9 Implications for the client contract
|
||||
`state:"active"` satisfies the pack-availability predicate (no dialog) while the
|
||||
group's existence prevents the crash — so an active non-openable placeholder is the
|
||||
first representation that neither crashes nor shows the dialog. However it exposes a
|
||||
**visible empty "pack"** and a **Browse-Packs navigation gate** (#5/#6), so it is NOT
|
||||
adopted. **Permanent fix NOT established.** Open follow-ups: (a) what happens if the
|
||||
user explicitly selects/opens the active placeholder (a later controlled test —
|
||||
server-safe per §16.1 but UX-unknown); (b) whether a count-gated My-Packs default or a
|
||||
representation that avoids rendering a fake tile can remove the empty-tile/navigation
|
||||
artifacts. Do NOT adopt `state:"active"` as the fix on this evidence alone.
|
||||
|
||||
---
|
||||
|
||||
## 17. Explicit Active-Placeholder Selection Test (EXECUTED 2026-08-13) — OBSERVED
|
||||
|
||||
**Purpose:** with the C′ active placeholder in place, characterize what happens when
|
||||
the user *explicitly opens* the empty 65534 My-Packs tile (the last open backend-side
|
||||
question).
|
||||
|
||||
**Server safety proof (re-confirmed, code `c89d43ea`):** `pack_by_id(65534)=None`;
|
||||
65534∉PACK_CATALOG∉unopenedPackIds. `store_buy`→`200 {}`; `purchased_items`→
|
||||
`200 {"itemData": last_pack}` (stale). No inventory/coin/profile mutation possible.
|
||||
|
||||
**Temporary C′ state:** container-only one-line patch `65534.state "inactive"→"active"`
|
||||
(patched `e1a4e1dc…`), restart (Pid 1560774). `unopenedPackIds=[]`, 65534
|
||||
active/mypacks/∉catalog, normal packs unchanged, flags ON/OFF, host code + profile
|
||||
unchanged. Marker `2026-08-13T00:53:09Z`.
|
||||
|
||||
**Manual selection behavior (operator report):** opened FUT → My Packs → the empty
|
||||
placeholder tile visible → selected/opened it ONCE:
|
||||
1. Dialog: **YES**. 2. Exact text: **"This pack is no longer available"**.
|
||||
3. Stays in My Packs: yes. 4. After closing the dialog → returns to My Packs.
|
||||
5. Then navigates back to the FUT Hub successfully. 6. **No crash.** 7. No spinner.
|
||||
8. **Navigation remains fully usable afterward.**
|
||||
|
||||
**Genuine request sequence (OBSERVED, marker `00:53:09Z`):** boot (`…/auth →
|
||||
userMassInfo → PUT store/transaction/0 {"state":"TRANSACTIONCANCEL"}→200 {} → hub →
|
||||
credits → purchasegroup`) then navigation (`hub→credits→purchasegroup` ×2 for the
|
||||
store/My-Packs views). **The explicit tile selection generated NO server request** —
|
||||
no `/store/transaction`, no `/purchased/items`, and NO reference to 65534 anywhere.
|
||||
The "no longer available" verdict is rendered **client-side**.
|
||||
|
||||
**Result class: S1 — pure client-side rejection.**
|
||||
|
||||
**Post-test profile/economy integrity (OBSERVED):** coins 29876776, nextItemId
|
||||
100004837, items 1995, purchased 0, `unopenedPackIds` `[]`, `last_pack` empty — ALL
|
||||
unchanged vs pre-test; 65534 not persisted in items or unopenedPackIds; profile
|
||||
SHA-256 `39bb3e83…` byte-identical. **Zero mutation.**
|
||||
|
||||
**UX assessment:** crash-safe ✓, economy-safe ✓ (no request even sent), navigation
|
||||
recoverable ✓. Blemishes: a **visible empty "0 items" tile**, a **"This pack is no
|
||||
longer available" dialog on explicit click**, and (from §16.5) the **Browse-Packs
|
||||
navigation gate** (must open My Packs first). Notably this is a *strict improvement*
|
||||
over the inactive-sentinel baseline, which throws "pack not available" immediately on
|
||||
STORE OPEN and bounces to the Hub; the active placeholder only errors if the user
|
||||
deliberately clicks the empty tile, and recovers cleanly.
|
||||
|
||||
**Active-placeholder verdict: MARGINALLY ACCEPTABLE.** Safe (crash + economy) and
|
||||
usable, but visibly imperfect (fake tile + click-dialog + browse nav gate). Not
|
||||
UNACCEPTABLE (no crash/economy risk, recoverable); not fully ACCEPTABLE (user-visible
|
||||
defects).
|
||||
|
||||
**Permanent-fix decision: P2.** The active sentinel technically works and is safe, but
|
||||
its UX is poor and — per Candidate F (CONTRADICTED) — **no backend-only *clean*
|
||||
solution exists** (the store's My-Packs resolution is Scaleform/movie-driven, not
|
||||
server-gated). Recommendation: keep the active placeholder as an optional/temporary
|
||||
backend compatibility mode (strictly better than the current inactive-sentinel
|
||||
baseline) and pursue a **client-side** fix for a fully clean zero-pack experience
|
||||
(hiding the fake tile / suppressing the forced My-Packs resolution). NOT implemented.
|
||||
|
||||
**Rollback verification:** container code restored to `c89d43ea…` (== host, ==
|
||||
pre-experiment), sentinel back to `inactive`; `docker restart` (Pid 1562665, StartedAt
|
||||
00:59:04Z); `unopenedPackIds=[]`, 65534 inactive/∉catalog, 70 absent, flags ON/OFF;
|
||||
host `utas_server.py` never edited; profile `39bb3e83…` unchanged. Bridge/Core
|
||||
untouched.
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
# FIFA 17 card-table provenance manifest
|
||||
# Source (authoritative): 10.10.0.105:/home/alex/Documents/OpenFUT/fifa17-recon/data/tables/
|
||||
# Dest (this repo): fifa17-recon/data/tables/
|
||||
# Verified 2026-08-12: source and dest byte-identical (sha256), order-independent.
|
||||
# Combined hash-of-hashes: 10f239add919089354d8dbff873fc9737b0a0f80f6ac41b1aa2a096c0ec8d331
|
||||
# 31 fcc_*.json + 5 staff tables = 36 files. Files are DECODED tables:
|
||||
# each carries {table, source, rowcount, schema[], rows[]}; card instances live in rows[].
|
||||
#
|
||||
0d1c9af7ae654c3e4363f18bb89bad03a0631056d36425c84b9a680fa989618c fcc_managerbonusvalues.json
|
||||
0e5d5309cd1d9322476f8047fc6eaf4a88f4f19211b8ea01fe4d28ddd3733134 fcc_healingcards.json
|
||||
1da80e390169ebb8ee8a6543e14b1191d9151f675797f01e23628f6c24d434c5 fcc_misccards.json
|
||||
2212b0ee8d962f0fb6bd346bee35fff6566e22539e397cc2e42bb6efd4dc3ad3 fcc_textposvalues_hd.json
|
||||
2a8e22ddb000b2c08f1a3e5eb47bc56ecf43f733ce6e7519498d332e4f439746 headcoachcards.json
|
||||
3a323e1c0688a4068ccd21be0c9d8e88a875ab10ce2158d3aed79fc14e65f0fb fcc_chemlinkcalc.json
|
||||
4a60bc4c0d8cb8d2b903e152a3dd5302348753568630818fde059b2de41f82ab fcc_leaguelogos.json
|
||||
5304114078200da4564d33612c955598f12a44bdf52f18274184b98922229b8b fcc_GrandStandPlayers.json
|
||||
5503291e381fee5008120615cd6d30a732b97636d4694a88f943eb14cb992741 fcc_leaguelogostickers.json
|
||||
550739c124ca915fb294954afe3d9d04fb7d1faf2b0c96930b2dcdb1bfe7ac8f fcc_formationcardspositions_kc.json
|
||||
5a5aabec1d40ffa21b8effb84f79e4b788cb42352aa592d71d95eba1db0d209e fcc_trainingcards.json
|
||||
6476e396f166905857d2ada4f12cc37645ca42efdca121e8e74270fbf7422336 managercards.json
|
||||
6546f602024973e20d04522a857c6c243473a177cab5b3be8400390651a42fab fcc_navcoords_hd.json
|
||||
6b0209647383e4e940d2af2c3bbb2185a4aac7ac0e799fe6b50ae52e7625710d fcc_contractcards.json
|
||||
6c52c83aafd9d9d3406e21c656762ac5cc0ba4522002f424e5593430bc5190f5 physiocards.json
|
||||
765687d7f1e5c6c989adf45b174a0fdab0f65597c83132304b53b9f859c02586 fcc_stadium.json
|
||||
79e50b07eecc47a0edf4d2a87782e904785e653937698cc712258a82fdf8b079 fcc_formationcardspositions_hd.json
|
||||
7d36e0fbb9349eabd4267215bacbe29d78ff621deeb8cab3380dcac72c535eb9 fcc_preferredformationcalcmid.json
|
||||
8122a4070901662fac97f675a3e4194dd5fd7e02194fdab204989af42676e268 fitnesscoachcards.json
|
||||
828f8b90241672b9f62a9bbd3cb219a1d3bd8856bd160cc46284f2958e08f7d4 fcc_discardcoins.json
|
||||
852bb82ed373881373d8610e6f4f2ca4406bac13da4bda9d6abba693d7cafc56 fcc_myclubscategories.json
|
||||
974dcbbe6a46c02dc97c77df6c270c9a7f09ba23bee23005ecf95fd114ee66a7 gkcoachcards.json
|
||||
9aa4b3b3f226202b21d2e9f96a1508ecce56abba64372099e090df101fce5eeb fcc_nationcalc.json
|
||||
9b6797991520c05f7448b28e66d160f96afc73eefd661ed8272b0a093b6ba89f fcc_kitcards.json
|
||||
9e8e6595fa8d3bcf963eb25bd9f9ea5d131aa92ed0e7e4ae3089adf5e1d55927 fcc_preferredformationcalcst.json
|
||||
a4e8ac2ba0a6db45f1f59fe384fbd39a8cee8fb72a72f846e52daca44f1c7ff9 fcc_myclubs.json
|
||||
bbf405e3a63b6fd03da1237b8b118764c57a40c797faf85d1e4691a1c95a840e fcc_balls.json
|
||||
ca1184bd85cff3308af0104077feda4357ef483fae6335fa964922eea4e94330 fcc_navcoords_kc.json
|
||||
ca3e4ab0f7892aac7473b774de4699c067c54647ca4a82cdd5fd1108c56ed894 fcc_badgecards.json
|
||||
ccdda8ad0a15f73a056fa336abde8739b346d12b78cb8adbea0f0677487bb598 fcc_preferredpositioncalc.json
|
||||
dbf95bddd456137e4b90a44bdd1f637458f4846ecd5c3ba6747d3f069c3f590f fcc_textposvalues_kc.json
|
||||
dd8c2c860b18d999877c37e0f63dac64ef7bb57bff5a2173960cde71242f9a34 fcc_bonusvalues.json
|
||||
df5b997b153941a5bb760ad5bb0fb23dc16cf8612b300559be23ac929b55eec6 fcc_leagues.json
|
||||
e4619db324a7848639a8ba53f513cf3ea153eeaf698de05d71e56c319b3cc424 fcc_coinrewards.json
|
||||
e6b1ca3ecb7c3923d77e73bda2e6c3f9794b9158354d566112f7979b33b4422c fcc_preferredformationcalcgk.json
|
||||
f54814d61b72dd2b6186e9e414df4fbfbdbea1732da6a4622f001a1ff03bc12a fcc_preferredformationcalcback.json
|
||||
@@ -0,0 +1,66 @@
|
||||
# Live UTAS wire captures — 2026-08-15
|
||||
|
||||
Fresh sanitised FIFA 17 UTAS wire, captured during the post-P1 staging A/B on a
|
||||
**real FIFA 17 client** (`10.10.0.105`) driving the isolated post-P1 candidate
|
||||
backend (`10.10.0.121`, host bin `fda40d12`, Core `fbb54ea`). Production untouched.
|
||||
|
||||
This rebuilds the primary-capture corpus that was lost to `.gitignore` (Known Issues
|
||||
#200 / "take sanitised captures on the next live FIFA run and commit them").
|
||||
|
||||
## Files
|
||||
- `utas-requests.sanitised.txt` — 10 real client requests (`---`-separated).
|
||||
- `utas-responses.sanitised.txt` — 12 responses.
|
||||
|
||||
Captured with `tcpdump` on the container netns (UTAS is plain HTTP on `:8099`),
|
||||
reassembled + split + **sanitised** by `openfut-staging/extract_http.py`. Redacted:
|
||||
`X-UT-SID`/`sid` (`<SID>`), `authCode` (`<AUTH>`), `deviceId` (`<DEV>`), `macAddress`
|
||||
(`<MAC>`), and any 32+char hex token (`<TOKEN>`). The raw `.pcap` is intentionally
|
||||
NOT committed (it is unsanitised).
|
||||
|
||||
## Route contracts captured (verbatim shapes)
|
||||
|
||||
### POST /openfut/account/sync (launcher control-plane, Python-served)
|
||||
Request (launcher → backend):
|
||||
```json
|
||||
{"personaId":33068179,"personaName":"CAGE","level":1,"experience":0,
|
||||
"experienceMax":1000,"accountFunds":0,"accountFundsCap":100000}
|
||||
```
|
||||
Response:
|
||||
```json
|
||||
{"account":{"personaId":33068179,"personaName":"CAGE","clubName":"OpenFUT",
|
||||
"clubAbbr":"OFC","level":1,"experience":0,"experienceMax":1000,"accountFunds":0,
|
||||
"accountFundsCap":100000,"profilePath":"accounts/33068179/fifa17_profile.json",
|
||||
"coins":29826776,"unopenedPacks":0},"status":"OK"}
|
||||
```
|
||||
NOTE: `coins` here is Python's STALE profile value (29,826,776) — Core's authoritative
|
||||
balance at capture time was 29,859,876. The launcher's `AccountSummary` parser is
|
||||
lenient and requires only `{personaId,personaName,level,experience,accountFunds,coins,
|
||||
unopenedPacks}` (clubName/clubAbbr/etc. ignored). BLOCKER for a Rust migration is NOT
|
||||
the shape — it is that Python `account/sync` also *selects the active profile* the
|
||||
still-Python-served userMassInfo envelope depends on, so it is coupled to the
|
||||
userMassInfo hybrid and cannot migrate standalone.
|
||||
|
||||
### GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true (empty My-Packs sentinel)
|
||||
The `mypacks` group carries the synthetic sentinel pack when My Packs is empty:
|
||||
```json
|
||||
{"assetId":65534,"displayGroup":{"priority":1,"value":"mypacks"},"id":65534,
|
||||
"packType":"GOLD","packContentInfo":{"goldQuantity":0,"itemQuantity":0,...},
|
||||
"state":"active","unopened":false}
|
||||
```
|
||||
Client renders empty My Packs AS Browse Packs (no crash) — the bug-6c resolver guard.
|
||||
|
||||
### GET /ut/game/fifa17/userMassInfo (RUST_OVERLAY hybrid, full 8 KB envelope captured)
|
||||
The complete envelope is in `utas-responses.sanitised.txt`: root `pileSizeClientData` +
|
||||
`userInfo` (with `clubName`, `clubAbbr`, `established`, `accountCreatedPlatformName`,
|
||||
`currencies` [Core coins overlaid by Rust], `won/draw/loss`, `clubNameChangeAllowed`,
|
||||
`divisionOffline/Online`, `purchased`, `feature`, `reliability`, `bidTokens`, `trophies`,
|
||||
`sessionCoinsBankBalance`, `actives`, `squadList`). Field spec is authoritative in
|
||||
`fifa17-recon/tools/utas_server.py::user_info()`. This is the target shape for a future
|
||||
full-Rust userMassInfo (needs the `clubAbbr`/`established` account triad, which Core lacks).
|
||||
|
||||
### Other captured request lines
|
||||
`POST /ut/auth`, `GET user/accountinfo`, `GET settings`, `GET phishing/trusteddevice`,
|
||||
`PUT match/reset`, `GET userMassInfo`, `PUT clientdata/userHubData`,
|
||||
`PUT /ut/v2/game/fifa17/store/transaction/0`.
|
||||
|
||||
See `openfut-staging/p1p-live-2026-08-15/AB_VERDICT.md` for the full A/B result.
|
||||
@@ -0,0 +1,117 @@
|
||||
POST /openfut/account/sync HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
Content-Type: application/json
|
||||
Content-Length: 131
|
||||
Connection: close
|
||||
|
||||
{"personaId":33068179,"personaName":"CAGE","level":1,"experience":0,"experienceMax":1000,"accountFunds":0,"accountFundsCap":100000}
|
||||
|
||||
---
|
||||
|
||||
GET /ut/game/fifa17/user/accountinfo HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
Connection: Close
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Accept-Encoding: gzip
|
||||
Easw-Session-Data-Nucleus-Id: 33068179
|
||||
Accept-Encoding: gzip
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
POST /ut/auth HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
Content-Length: 380
|
||||
Connection: Close
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
Hash: <TOKEN>
|
||||
|
||||
{"isReadOnly":false,"priorityLevel":6,"sku":"FFA17PCC","nucleusPersonaPlatform":"pc","clientVersion":3,"nuc":33068179,"nucleusPersonaId":33068179,"nucleusPersonaDisplayName":"CAGE","locale":"en-US","regionCode":"US","deviceId":"<DEV>","macAddress":"<MAC>","method":"authcode","identification":{"authCode":"<AUTH>"}}
|
||||
|
||||
---
|
||||
|
||||
GET /ut/game/fifa17/settings HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-UT-SID: <SID>
|
||||
Accept-Encoding: gzip
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
GET /ut/game/fifa17/phishing/trusteddevice?deviceId=<DEV> HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-UT-SID: <SID>
|
||||
Accept-Encoding: gzip
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
PUT /ut/game/fifa17/match/reset HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
Content-Length: 0
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-UT-SID: <SID>
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
GET /ut/game/fifa17/userMassInfo HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-UT-SID: <SID>
|
||||
Accept-Encoding: gzip
|
||||
|
||||
|
||||
|
||||
---
|
||||
|
||||
PUT /ut/v2/game/fifa17/store/transaction/0 HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
Content-Length: 29
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-UT-SID: <SID>
|
||||
|
||||
{"state":"TRANSACTIONCANCEL"}
|
||||
|
||||
---
|
||||
|
||||
PUT /ut/game/fifa17/clientdata/userHubData HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
Content-Length: 53
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-UT-SID: <SID>
|
||||
|
||||
{"entries":[{"key":0,"value":0},{"key":1,"value":1}]}
|
||||
|
||||
---
|
||||
|
||||
GET /ut/game/fifa17/store/purchasegroup/all?ppInfo=true HTTP/1.1
|
||||
Host: 10.10.0.121:8099
|
||||
User-Agent: ProtoHttp 1.3/DS 15.1.2.1.0 (Windows)
|
||||
Accept: application/json
|
||||
Content-Type: application/json
|
||||
X-UT-SID: <SID>
|
||||
Accept-Encoding: gzip
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"champion": {
|
||||
"bytes": 2,
|
||||
"path": "/ut/game/fifa17/champion",
|
||||
"status": 200
|
||||
},
|
||||
"clubUser": {
|
||||
"bytes": 2,
|
||||
"path": "/ut/game/fifa17/clubUser",
|
||||
"status": 200
|
||||
},
|
||||
"defid": {
|
||||
"bytes": 600,
|
||||
"path": "/ut/game/fifa17/defid?definitionId=200389",
|
||||
"status": 200
|
||||
},
|
||||
"defs_resource": {
|
||||
"bytes": 600,
|
||||
"path": "/ut/game/fifa17/item/resource?resourceId=200389",
|
||||
"status": 200
|
||||
},
|
||||
"draft_state": {
|
||||
"bytes": 118,
|
||||
"path": "/ut/game/fifa17/squad/mode/draft/state",
|
||||
"status": 200
|
||||
},
|
||||
"marketdata": {
|
||||
"bytes": 110,
|
||||
"path": "/ut/game/fifa17/marketdata/pricelimits?defId=200389,200104",
|
||||
"status": 200
|
||||
},
|
||||
"sbs_sets": {
|
||||
"bytes": 537,
|
||||
"path": "/ut/game/fifa17/sbs/sets",
|
||||
"status": 200
|
||||
},
|
||||
"season": {
|
||||
"bytes": 2,
|
||||
"path": "/ut/game/fifa17/season",
|
||||
"status": 200
|
||||
},
|
||||
"squad_0": {
|
||||
"bytes": 7792,
|
||||
"path": "/ut/game/fifa17/squad/0",
|
||||
"status": 200
|
||||
},
|
||||
"tournament": {
|
||||
"bytes": 2,
|
||||
"path": "/ut/game/fifa17/tournament",
|
||||
"status": 200
|
||||
},
|
||||
"user": {
|
||||
"bytes": 751,
|
||||
"path": "/ut/game/fifa17/user",
|
||||
"status": 200
|
||||
},
|
||||
"user_list": {
|
||||
"bytes": 2,
|
||||
"path": "/ut/game/fifa17/user/list",
|
||||
"status": 200
|
||||
},
|
||||
"watchList": {
|
||||
"bytes": 52,
|
||||
"path": "/ut/game/fifa17/watchList",
|
||||
"status": 200
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"itemData": [
|
||||
{
|
||||
"assetId": 200389,
|
||||
"attributeList": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 70
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"value": 70
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"value": 70
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"value": 70
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"value": 70
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"value": 70
|
||||
}
|
||||
],
|
||||
"cardType": 0,
|
||||
"cardassetid": 200389,
|
||||
"cardsubtypeid": 0,
|
||||
"commodityId": 200389,
|
||||
"commonName": "Player",
|
||||
"definitionId": 200389,
|
||||
"id": 200389,
|
||||
"itemState": "free",
|
||||
"itemType": "player",
|
||||
"lastName": "Player",
|
||||
"leagueId": 0,
|
||||
"name": "Player",
|
||||
"nation": 0,
|
||||
"playStyle": 250,
|
||||
"preferredPosition": "CM",
|
||||
"rareflag": 1,
|
||||
"rating": 75,
|
||||
"resourceId": 200389,
|
||||
"teamid": 0,
|
||||
"untradeable": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"itemData": [
|
||||
{
|
||||
"assetId": 200389,
|
||||
"attributeList": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 70
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"value": 70
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"value": 70
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"value": 70
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"value": 70
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"value": 70
|
||||
}
|
||||
],
|
||||
"cardType": 0,
|
||||
"cardassetid": 200389,
|
||||
"cardsubtypeid": 0,
|
||||
"commodityId": 200389,
|
||||
"commonName": "Player",
|
||||
"definitionId": 200389,
|
||||
"id": 200389,
|
||||
"itemState": "free",
|
||||
"itemType": "player",
|
||||
"lastName": "Player",
|
||||
"leagueId": 0,
|
||||
"name": "Player",
|
||||
"nation": 0,
|
||||
"playStyle": 250,
|
||||
"preferredPosition": "CM",
|
||||
"rareflag": 1,
|
||||
"rating": 75,
|
||||
"resourceId": 200389,
|
||||
"teamid": 0,
|
||||
"untradeable": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
[
|
||||
{
|
||||
"gamesWonCurrentMatch": 0,
|
||||
"roundsInfo": [],
|
||||
"squadState": "INVALID",
|
||||
"stateParam1": "INVALID",
|
||||
"stateParam2": "0"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
[
|
||||
{
|
||||
"defId": 200389,
|
||||
"maxPrice": 15000,
|
||||
"minPrice": 150
|
||||
},
|
||||
{
|
||||
"defId": 200104,
|
||||
"maxPrice": 15000,
|
||||
"minPrice": 150
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"categories": [
|
||||
{
|
||||
"categoryId": 1,
|
||||
"name": "Foundations",
|
||||
"priority": 1,
|
||||
"sets": [
|
||||
{
|
||||
"awards": [],
|
||||
"categoryId": 1,
|
||||
"challengesCompletedCount": 0,
|
||||
"challengesCount": 1,
|
||||
"description": "Submit an 11-player squad.",
|
||||
"endTime": 4102444800,
|
||||
"hidden": false,
|
||||
"name": "Bronze Challenge",
|
||||
"priority": 1,
|
||||
"setId": 1
|
||||
},
|
||||
{
|
||||
"awards": [],
|
||||
"categoryId": 1,
|
||||
"challengesCompletedCount": 0,
|
||||
"challengesCount": 1,
|
||||
"description": "Get started with your first SBC.",
|
||||
"endTime": 4102444800,
|
||||
"hidden": false,
|
||||
"name": "Simple Start",
|
||||
"priority": 2,
|
||||
"setId": 2
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,707 @@
|
||||
{
|
||||
"actives": [],
|
||||
"captain": 100000001,
|
||||
"changed": 0,
|
||||
"chemistry": 49,
|
||||
"custom": "[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,50,50,0,50,40,65,0,65,50,50,1]",
|
||||
"formation": "f433",
|
||||
"id": 0,
|
||||
"kicktakers": [
|
||||
{
|
||||
"dream": false,
|
||||
"id": 100000001,
|
||||
"index": 0
|
||||
},
|
||||
{
|
||||
"dream": false,
|
||||
"id": 100000001,
|
||||
"index": 1
|
||||
},
|
||||
{
|
||||
"dream": false,
|
||||
"id": 100000001,
|
||||
"index": 2
|
||||
},
|
||||
{
|
||||
"dream": false,
|
||||
"id": 100000001,
|
||||
"index": 3
|
||||
},
|
||||
{
|
||||
"dream": false,
|
||||
"id": 100000001,
|
||||
"index": 4
|
||||
}
|
||||
],
|
||||
"manager": [
|
||||
{
|
||||
"dream": false,
|
||||
"id": 100000427
|
||||
}
|
||||
],
|
||||
"personaId": 33068179,
|
||||
"players": [
|
||||
{
|
||||
"index": 0,
|
||||
"itemData": {
|
||||
"assetId": 200389,
|
||||
"attributeList": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 83
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"value": 90
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"value": 77
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"value": 82
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"value": 50
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"value": 87
|
||||
}
|
||||
],
|
||||
"cardassetid": 200389,
|
||||
"cardsubtypeid": 0,
|
||||
"contract": 7,
|
||||
"definitionId": 200389,
|
||||
"fitness": 99,
|
||||
"id": 100000003,
|
||||
"itemState": "free",
|
||||
"itemType": "player",
|
||||
"leagueId": 53,
|
||||
"nation": 44,
|
||||
"owners": 1,
|
||||
"playStyle": 250,
|
||||
"preferredPosition": "GK",
|
||||
"rareflag": 1,
|
||||
"rating": 87,
|
||||
"resourceId": 200389,
|
||||
"teamid": 240,
|
||||
"untradeable": true
|
||||
},
|
||||
"kitNumber": 1
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"itemData": {
|
||||
"assetId": 197445,
|
||||
"attributeList": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 86
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"value": 73
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"value": 81
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"value": 83
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"value": 83
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"value": 73
|
||||
}
|
||||
],
|
||||
"cardassetid": 197445,
|
||||
"cardsubtypeid": 0,
|
||||
"contract": 7,
|
||||
"definitionId": 197445,
|
||||
"fitness": 99,
|
||||
"id": 100000006,
|
||||
"itemState": "free",
|
||||
"itemType": "player",
|
||||
"leagueId": 19,
|
||||
"nation": 4,
|
||||
"owners": 1,
|
||||
"playStyle": 250,
|
||||
"preferredPosition": "LB",
|
||||
"rareflag": 1,
|
||||
"rating": 87,
|
||||
"resourceId": 197445,
|
||||
"teamid": 21,
|
||||
"untradeable": true
|
||||
},
|
||||
"kitNumber": 4
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"itemData": {
|
||||
"assetId": 155862,
|
||||
"attributeList": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 78
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"value": 63
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"value": 70
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"value": 70
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"value": 87
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"value": 83
|
||||
}
|
||||
],
|
||||
"cardassetid": 155862,
|
||||
"cardsubtypeid": 0,
|
||||
"contract": 7,
|
||||
"definitionId": 155862,
|
||||
"fitness": 99,
|
||||
"id": 100000005,
|
||||
"itemState": "free",
|
||||
"itemType": "player",
|
||||
"leagueId": 53,
|
||||
"nation": 45,
|
||||
"owners": 1,
|
||||
"playStyle": 250,
|
||||
"preferredPosition": "CB",
|
||||
"rareflag": 1,
|
||||
"rating": 89,
|
||||
"resourceId": 155862,
|
||||
"teamid": 243,
|
||||
"untradeable": true
|
||||
},
|
||||
"kitNumber": 3
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"itemData": {
|
||||
"assetId": 182521,
|
||||
"attributeList": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 45
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"value": 80
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"value": 88
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"value": 79
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"value": 69
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"value": 70
|
||||
}
|
||||
],
|
||||
"cardassetid": 182521,
|
||||
"cardsubtypeid": 0,
|
||||
"contract": 7,
|
||||
"definitionId": 182521,
|
||||
"fitness": 99,
|
||||
"id": 100000008,
|
||||
"itemState": "free",
|
||||
"itemType": "player",
|
||||
"leagueId": 53,
|
||||
"nation": 21,
|
||||
"owners": 1,
|
||||
"playStyle": 250,
|
||||
"preferredPosition": "CM",
|
||||
"rareflag": 1,
|
||||
"rating": 88,
|
||||
"resourceId": 182521,
|
||||
"teamid": 243,
|
||||
"untradeable": true
|
||||
},
|
||||
"kitNumber": 6
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"itemData": {
|
||||
"assetId": 189332,
|
||||
"attributeList": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 93
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"value": 69
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"value": 75
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"value": 83
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"value": 81
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"value": 75
|
||||
}
|
||||
],
|
||||
"cardassetid": 189332,
|
||||
"cardsubtypeid": 0,
|
||||
"contract": 7,
|
||||
"definitionId": 189332,
|
||||
"fitness": 99,
|
||||
"id": 100000007,
|
||||
"itemState": "free",
|
||||
"itemType": "player",
|
||||
"leagueId": 53,
|
||||
"nation": 45,
|
||||
"owners": 1,
|
||||
"playStyle": 250,
|
||||
"preferredPosition": "LB",
|
||||
"rareflag": 1,
|
||||
"rating": 86,
|
||||
"resourceId": 189332,
|
||||
"teamid": 241,
|
||||
"untradeable": true
|
||||
},
|
||||
"kitNumber": 5
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"itemData": {
|
||||
"assetId": 158023,
|
||||
"attributeList": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 89
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"value": 90
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"value": 86
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"value": 96
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"value": 26
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"value": 61
|
||||
}
|
||||
],
|
||||
"cardassetid": 158023,
|
||||
"cardsubtypeid": 0,
|
||||
"contract": 7,
|
||||
"definitionId": 158023,
|
||||
"fitness": 99,
|
||||
"id": 100000002,
|
||||
"itemState": "free",
|
||||
"itemType": "player",
|
||||
"leagueId": 53,
|
||||
"nation": 52,
|
||||
"owners": 1,
|
||||
"playStyle": 250,
|
||||
"preferredPosition": "RW",
|
||||
"rareflag": 1,
|
||||
"rating": 93,
|
||||
"resourceId": 158023,
|
||||
"teamid": 241,
|
||||
"untradeable": true
|
||||
},
|
||||
"kitNumber": 9
|
||||
},
|
||||
{
|
||||
"index": 6,
|
||||
"itemData": {
|
||||
"assetId": 183907,
|
||||
"attributeList": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 79
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"value": 50
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"value": 72
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"value": 68
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"value": 90
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"value": 85
|
||||
}
|
||||
],
|
||||
"cardassetid": 183907,
|
||||
"cardsubtypeid": 0,
|
||||
"contract": 7,
|
||||
"definitionId": 183907,
|
||||
"fitness": 99,
|
||||
"id": 100000004,
|
||||
"itemState": "free",
|
||||
"itemType": "player",
|
||||
"leagueId": 19,
|
||||
"nation": 21,
|
||||
"owners": 1,
|
||||
"playStyle": 250,
|
||||
"preferredPosition": "CB",
|
||||
"rareflag": 1,
|
||||
"rating": 90,
|
||||
"resourceId": 183907,
|
||||
"teamid": 21,
|
||||
"untradeable": true
|
||||
},
|
||||
"kitNumber": 2
|
||||
},
|
||||
{
|
||||
"index": 7,
|
||||
"itemData": {
|
||||
"assetId": 183277,
|
||||
"attributeList": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 90
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"value": 81
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"value": 82
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"value": 91
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"value": 32
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"value": 64
|
||||
}
|
||||
],
|
||||
"cardassetid": 183277,
|
||||
"cardsubtypeid": 0,
|
||||
"contract": 7,
|
||||
"definitionId": 183277,
|
||||
"fitness": 99,
|
||||
"id": 100000009,
|
||||
"itemState": "free",
|
||||
"itemType": "player",
|
||||
"leagueId": 13,
|
||||
"nation": 7,
|
||||
"owners": 1,
|
||||
"playStyle": 250,
|
||||
"preferredPosition": "LM",
|
||||
"rareflag": 1,
|
||||
"rating": 88,
|
||||
"resourceId": 183277,
|
||||
"teamid": 5,
|
||||
"untradeable": true
|
||||
},
|
||||
"kitNumber": 7
|
||||
},
|
||||
{
|
||||
"index": 8,
|
||||
"itemData": {
|
||||
"assetId": 176580,
|
||||
"attributeList": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 82
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"value": 90
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"value": 79
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"value": 87
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"value": 42
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"value": 79
|
||||
}
|
||||
],
|
||||
"cardassetid": 176580,
|
||||
"cardsubtypeid": 0,
|
||||
"contract": 7,
|
||||
"definitionId": 176580,
|
||||
"fitness": 99,
|
||||
"id": 100000010,
|
||||
"itemState": "free",
|
||||
"itemType": "player",
|
||||
"leagueId": 53,
|
||||
"nation": 60,
|
||||
"owners": 1,
|
||||
"playStyle": 250,
|
||||
"preferredPosition": "ST",
|
||||
"rareflag": 1,
|
||||
"rating": 92,
|
||||
"resourceId": 176580,
|
||||
"teamid": 241,
|
||||
"untradeable": true
|
||||
},
|
||||
"kitNumber": 10
|
||||
},
|
||||
{
|
||||
"index": 9,
|
||||
"itemData": {
|
||||
"assetId": 188545,
|
||||
"attributeList": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 81
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"value": 87
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"value": 74
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"value": 85
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"value": 38
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"value": 82
|
||||
}
|
||||
],
|
||||
"cardassetid": 188545,
|
||||
"cardsubtypeid": 0,
|
||||
"contract": 7,
|
||||
"definitionId": 188545,
|
||||
"fitness": 99,
|
||||
"id": 100000025,
|
||||
"itemState": "free",
|
||||
"itemType": "player",
|
||||
"leagueId": 19,
|
||||
"nation": 37,
|
||||
"owners": 1,
|
||||
"pile": "club",
|
||||
"playStyle": 250,
|
||||
"preferredPosition": "ST",
|
||||
"rareflag": 1,
|
||||
"rating": 90,
|
||||
"resourceId": 188545,
|
||||
"teamid": 21,
|
||||
"untradeable": true
|
||||
},
|
||||
"kitNumber": 11
|
||||
},
|
||||
{
|
||||
"index": 10,
|
||||
"itemData": {
|
||||
"assetId": 20801,
|
||||
"attributeList": [
|
||||
{
|
||||
"index": 0,
|
||||
"value": 92
|
||||
},
|
||||
{
|
||||
"index": 1,
|
||||
"value": 92
|
||||
},
|
||||
{
|
||||
"index": 2,
|
||||
"value": 81
|
||||
},
|
||||
{
|
||||
"index": 3,
|
||||
"value": 91
|
||||
},
|
||||
{
|
||||
"index": 4,
|
||||
"value": 33
|
||||
},
|
||||
{
|
||||
"index": 5,
|
||||
"value": 80
|
||||
}
|
||||
],
|
||||
"cardassetid": 20801,
|
||||
"cardsubtypeid": 0,
|
||||
"contract": 7,
|
||||
"definitionId": 20801,
|
||||
"fitness": 99,
|
||||
"id": 100000001,
|
||||
"itemState": "free",
|
||||
"itemType": "player",
|
||||
"leagueId": 53,
|
||||
"nation": 38,
|
||||
"owners": 1,
|
||||
"playStyle": 250,
|
||||
"preferredPosition": "LW",
|
||||
"rareflag": 1,
|
||||
"rating": 94,
|
||||
"resourceId": 20801,
|
||||
"teamid": 243,
|
||||
"untradeable": true
|
||||
},
|
||||
"kitNumber": 8
|
||||
},
|
||||
{
|
||||
"index": 11,
|
||||
"itemData": {
|
||||
"dream": false,
|
||||
"id": 0
|
||||
},
|
||||
"kitNumber": 0
|
||||
},
|
||||
{
|
||||
"index": 12,
|
||||
"itemData": {
|
||||
"dream": false,
|
||||
"id": 0
|
||||
},
|
||||
"kitNumber": 0
|
||||
},
|
||||
{
|
||||
"index": 13,
|
||||
"itemData": {
|
||||
"dream": false,
|
||||
"id": 0
|
||||
},
|
||||
"kitNumber": 0
|
||||
},
|
||||
{
|
||||
"index": 14,
|
||||
"itemData": {
|
||||
"dream": false,
|
||||
"id": 0
|
||||
},
|
||||
"kitNumber": 0
|
||||
},
|
||||
{
|
||||
"index": 15,
|
||||
"itemData": {
|
||||
"dream": false,
|
||||
"id": 0
|
||||
},
|
||||
"kitNumber": 0
|
||||
},
|
||||
{
|
||||
"index": 16,
|
||||
"itemData": {
|
||||
"dream": false,
|
||||
"id": 0
|
||||
},
|
||||
"kitNumber": 0
|
||||
},
|
||||
{
|
||||
"index": 17,
|
||||
"itemData": {
|
||||
"dream": false,
|
||||
"id": 0
|
||||
},
|
||||
"kitNumber": 0
|
||||
},
|
||||
{
|
||||
"index": 18,
|
||||
"itemData": {
|
||||
"dream": false,
|
||||
"id": 0
|
||||
},
|
||||
"kitNumber": 0
|
||||
},
|
||||
{
|
||||
"index": 19,
|
||||
"itemData": {
|
||||
"dream": false,
|
||||
"id": 0
|
||||
},
|
||||
"kitNumber": 0
|
||||
},
|
||||
{
|
||||
"index": 20,
|
||||
"itemData": {
|
||||
"dream": false,
|
||||
"id": 0
|
||||
},
|
||||
"kitNumber": 0
|
||||
},
|
||||
{
|
||||
"index": 21,
|
||||
"itemData": {
|
||||
"dream": false,
|
||||
"id": 0
|
||||
},
|
||||
"kitNumber": 0
|
||||
},
|
||||
{
|
||||
"index": 22,
|
||||
"itemData": {
|
||||
"dream": false,
|
||||
"id": 0
|
||||
},
|
||||
"kitNumber": 0
|
||||
}
|
||||
],
|
||||
"rating": 90,
|
||||
"squadName": "OpenFUT",
|
||||
"squadType": "REGULAR_SQUAD",
|
||||
"starRating": 90
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"userInfo": {
|
||||
"accountCreatedPlatformName": "pc",
|
||||
"actives": [],
|
||||
"bidTokens": {
|
||||
"count": 0,
|
||||
"updateTime": 0
|
||||
},
|
||||
"clubAbbr": "OFC",
|
||||
"clubName": "OpenFUT",
|
||||
"clubNameChangeAllowed": false,
|
||||
"currencies": [
|
||||
{
|
||||
"active": true,
|
||||
"finalFunds": 29876776,
|
||||
"funds": 29876776,
|
||||
"name": "coins"
|
||||
},
|
||||
{
|
||||
"active": true,
|
||||
"finalFunds": 0,
|
||||
"funds": 0,
|
||||
"name": "points"
|
||||
}
|
||||
],
|
||||
"divisionOffline": 10,
|
||||
"divisionOnline": 10,
|
||||
"draw": 0,
|
||||
"established": "2026",
|
||||
"feature": {},
|
||||
"loss": 0,
|
||||
"personaId": 33068179,
|
||||
"purchased": false,
|
||||
"reliability": {
|
||||
"matchUnfinishedTime": 0,
|
||||
"reliability": 100
|
||||
},
|
||||
"sessionCoinsBankBalance": 0,
|
||||
"squadList": {
|
||||
"squad": [
|
||||
{
|
||||
"chemistry": 49,
|
||||
"formation": "f433",
|
||||
"id": 0,
|
||||
"rating": 89,
|
||||
"squadName": "OpenFUT",
|
||||
"squadType": "REGULAR_SQUAD"
|
||||
}
|
||||
]
|
||||
},
|
||||
"trophies": 0,
|
||||
"won": 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"auctionInfo": [],
|
||||
"credits": 29876776,
|
||||
"total": 0
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"purchase": [
|
||||
{
|
||||
"assetId": 1,
|
||||
"id": 1,
|
||||
"packType": "BRONZE",
|
||||
"description": "Bronze Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 400,
|
||||
"finalFunds": 400
|
||||
}
|
||||
],
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 5,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 0,
|
||||
"itemQuantity": 5
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 5,
|
||||
"packType": "GOLD",
|
||||
"description": "Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 5000,
|
||||
"finalFunds": 5000
|
||||
}
|
||||
],
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 7,
|
||||
"rareQuantity": 7,
|
||||
"itemQuantity": 7
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 6,
|
||||
"id": 6,
|
||||
"packType": "GOLD",
|
||||
"description": "Premium Gold",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 15000,
|
||||
"finalFunds": 15000
|
||||
}
|
||||
],
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"itemQuantity": 11
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 7,
|
||||
"id": 7,
|
||||
"packType": "GOLD",
|
||||
"description": "Special Players Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 4,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 25000,
|
||||
"finalFunds": 25000
|
||||
}
|
||||
],
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"itemQuantity": 11
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "special"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 65534,
|
||||
"id": 65534,
|
||||
"packType": "GOLD",
|
||||
"description": "",
|
||||
"state": "inactive",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 0,
|
||||
"itemQuantity": 0
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "mypacks",
|
||||
"priority": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"purchase": [
|
||||
{
|
||||
"assetId": 1,
|
||||
"id": 1,
|
||||
"packType": "BRONZE",
|
||||
"description": "Bronze Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 400,
|
||||
"finalFunds": 400
|
||||
}
|
||||
],
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 5,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 0,
|
||||
"itemQuantity": 5
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 5,
|
||||
"packType": "GOLD",
|
||||
"description": "Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 5000,
|
||||
"finalFunds": 5000
|
||||
}
|
||||
],
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 7,
|
||||
"rareQuantity": 7,
|
||||
"itemQuantity": 7
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 6,
|
||||
"id": 6,
|
||||
"packType": "GOLD",
|
||||
"description": "Premium Gold",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 15000,
|
||||
"finalFunds": 15000
|
||||
}
|
||||
],
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"itemQuantity": 11
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 7,
|
||||
"id": 7,
|
||||
"packType": "GOLD",
|
||||
"description": "Special Players Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 4,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 25000,
|
||||
"finalFunds": 25000
|
||||
}
|
||||
],
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"itemQuantity": 11
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "special"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 65534,
|
||||
"id": 65534,
|
||||
"packType": "GOLD",
|
||||
"description": "",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 0,
|
||||
"itemQuantity": 0
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "mypacks",
|
||||
"priority": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"purchase": [{"assetId": 1, "id": 1, "packType": "BRONZE", "description": "Bronze Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 1, "currencies": [{"name": "coins", "funds": 400, "finalFunds": 400}], "extPrice": {"finalPrice": {"amount": 4, "currency": "mtx"}, "originalPrice": {"amount": 4, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 5, "silverQuantity": 0, "goldQuantity": 0, "rareQuantity": 0, "itemQuantity": 5}, "unopened": false, "displayGroup": {"value": "bronze"}}, {"assetId": 5, "id": 5, "packType": "GOLD", "description": "Gold Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 2, "currencies": [{"name": "coins", "funds": 5000, "finalFunds": 5000}], "extPrice": {"finalPrice": {"amount": 50, "currency": "mtx"}, "originalPrice": {"amount": 50, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 7, "rareQuantity": 7, "itemQuantity": 7}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 6, "id": 6, "packType": "GOLD", "description": "Premium Gold", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 3, "currencies": [{"name": "coins", "funds": 15000, "finalFunds": 15000}], "extPrice": {"finalPrice": {"amount": 150, "currency": "mtx"}, "originalPrice": {"amount": 150, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 7, "id": 7, "packType": "GOLD", "description": "Special Players Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 4, "currencies": [{"name": "coins", "funds": 25000, "finalFunds": 25000}], "extPrice": {"finalPrice": {"amount": 250, "currency": "mtx"}, "originalPrice": {"amount": 250, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "special"}}], "timestamp": 1596326400}
|
||||
@@ -0,0 +1,173 @@
|
||||
{
|
||||
"purchase": [
|
||||
{
|
||||
"assetId": 1,
|
||||
"id": 1,
|
||||
"packType": "BRONZE",
|
||||
"description": "Bronze Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 400,
|
||||
"finalFunds": 400
|
||||
}
|
||||
],
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 5,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 0,
|
||||
"itemQuantity": 5
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 5,
|
||||
"packType": "GOLD",
|
||||
"description": "Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 5000,
|
||||
"finalFunds": 5000
|
||||
}
|
||||
],
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 7,
|
||||
"rareQuantity": 7,
|
||||
"itemQuantity": 7
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 6,
|
||||
"id": 6,
|
||||
"packType": "GOLD",
|
||||
"description": "Premium Gold",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 15000,
|
||||
"finalFunds": 15000
|
||||
}
|
||||
],
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"itemQuantity": 11
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 7,
|
||||
"id": 7,
|
||||
"packType": "GOLD",
|
||||
"description": "Special Players Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 4,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 25000,
|
||||
"finalFunds": 25000
|
||||
}
|
||||
],
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"itemQuantity": 11
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "special"
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"purchase": [{"assetId": 1, "id": 1, "packType": "BRONZE", "description": "Bronze Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 1, "currencies": [{"name": "coins", "funds": 400, "finalFunds": 400}], "extPrice": {"finalPrice": {"amount": 4, "currency": "mtx"}, "originalPrice": {"amount": 4, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 5, "silverQuantity": 0, "goldQuantity": 0, "rareQuantity": 0, "itemQuantity": 5}, "unopened": false, "displayGroup": {"value": "bronze"}}, {"assetId": 5, "id": 5, "packType": "GOLD", "description": "Gold Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 2, "currencies": [{"name": "coins", "funds": 5000, "finalFunds": 5000}], "extPrice": {"finalPrice": {"amount": 50, "currency": "mtx"}, "originalPrice": {"amount": 50, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 7, "rareQuantity": 7, "itemQuantity": 7}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 6, "id": 6, "packType": "GOLD", "description": "Premium Gold", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 3, "currencies": [{"name": "coins", "funds": 15000, "finalFunds": 15000}], "extPrice": {"finalPrice": {"amount": 150, "currency": "mtx"}, "originalPrice": {"amount": 150, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "gold"}}, {"assetId": 7, "id": 7, "packType": "GOLD", "description": "Special Players Pack", "state": "active", "saleType": "promo", "limitType": "NONE", "quantity": 0, "purchaseLimit": 0, "purchaseCount": 0, "isPremium": false, "sortPriority": 4, "currencies": [{"name": "coins", "funds": 25000, "finalFunds": 25000}], "extPrice": {"finalPrice": {"amount": 250, "currency": "mtx"}, "originalPrice": {"amount": 250, "currency": "mtx"}}, "packContentInfo": {"bronzeQuantity": 0, "silverQuantity": 0, "goldQuantity": 11, "rareQuantity": 11, "itemQuantity": 11}, "unopened": false, "displayGroup": {"value": "special"}}], "timestamp": 1596326400}
|
||||
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"purchase": [
|
||||
{
|
||||
"assetId": 1,
|
||||
"id": 1,
|
||||
"packType": "BRONZE",
|
||||
"description": "Bronze Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 400,
|
||||
"finalFunds": 400
|
||||
}
|
||||
],
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 4,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 5,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 0,
|
||||
"rareQuantity": 0,
|
||||
"itemQuantity": 5
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "bronze"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 5,
|
||||
"id": 5,
|
||||
"packType": "GOLD",
|
||||
"description": "Gold Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 2,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 5000,
|
||||
"finalFunds": 5000
|
||||
}
|
||||
],
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 50,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 7,
|
||||
"rareQuantity": 7,
|
||||
"itemQuantity": 7
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 6,
|
||||
"id": 6,
|
||||
"packType": "GOLD",
|
||||
"description": "Premium Gold",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 3,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 15000,
|
||||
"finalFunds": 15000
|
||||
}
|
||||
],
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 150,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"itemQuantity": 11
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "gold"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 7,
|
||||
"id": 7,
|
||||
"packType": "GOLD",
|
||||
"description": "Special Players Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 4,
|
||||
"currencies": [
|
||||
{
|
||||
"name": "coins",
|
||||
"funds": 25000,
|
||||
"finalFunds": 25000
|
||||
}
|
||||
],
|
||||
"extPrice": {
|
||||
"finalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
},
|
||||
"originalPrice": {
|
||||
"amount": 250,
|
||||
"currency": "mtx"
|
||||
}
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"itemQuantity": 11
|
||||
},
|
||||
"unopened": false,
|
||||
"displayGroup": {
|
||||
"value": "special"
|
||||
}
|
||||
},
|
||||
{
|
||||
"assetId": 70,
|
||||
"id": 70,
|
||||
"packType": "GOLD",
|
||||
"description": "Reward Special Players Pack",
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": 1,
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": 0,
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": 11,
|
||||
"rareQuantity": 11,
|
||||
"itemQuantity": 11
|
||||
},
|
||||
"unopened": true,
|
||||
"displayGroup": {
|
||||
"value": "mypacks",
|
||||
"priority": 1
|
||||
}
|
||||
}
|
||||
],
|
||||
"timestamp": 1596326400
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
# FIFA 17 — Clean Empty-My-Packs client fix (DESIGN / RESEARCH ONLY)
|
||||
|
||||
Status: **design only — no client binary/movie changes made.** This is the client-side
|
||||
follow-up to bug 6c. The backend already ships a compatibility workaround (P2, active
|
||||
non-openable sentinel 65534; see `docs/evidence/STORE_TILE_6C.md` §17 and
|
||||
`FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md`). This plan describes what a *client-side*
|
||||
fix would need to change so the backend shim can eventually become unnecessary for
|
||||
patched clients.
|
||||
|
||||
Do NOT patch the executable, DLLs, or Scaleform movies in this task.
|
||||
|
||||
## 1. Established client-side evidence
|
||||
|
||||
Binary: `CardsDLL_Win64_retail.dll`
|
||||
SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c`
|
||||
(dump load base `0x00006FFFFC120000`; RE-space base `0x180000000`). `FIFA17.exe`
|
||||
(`29c31cef…`) is Denuvo-packed (decrypts only in live memory).
|
||||
|
||||
Store category pipeline (all decompiled; see `docs/plan-2026-08-05-store-subsystem.md`):
|
||||
- `FUN_1800150d0` — builds display groups from `purchase[]`; a `mypacks` group exists
|
||||
iff some pack has `displayGroup.value=="mypacks"`. `group+0x104=(value=="mypacks")`,
|
||||
tiles in `group+0x40`, ordinal in `group+0x00` (1-based creation order).
|
||||
- `FUN_18007dab0` → `FUN_1800147f0(model, screen+0x290, …)` — renders/resolves a
|
||||
category. `screen+0x290==0` lists group tiles (`FUN_180014610`); otherwise
|
||||
`FUN_180014420` exact-matches the ordinal and **returns NULL on a miss**, after
|
||||
which `FUN_1800147f0` dereferences `[RAX+0x48]` with **no null guard** →
|
||||
**crash at `0x180014882`** (`ACCESS_VIOLATION` read of `0x48`, minidump-confirmed).
|
||||
- `screen+0x290` is written in exactly two CardsDLL sites: ctor `FUN_18007d1a0`
|
||||
writes `0`; **`FUN_18007e7f0` case `0x7551` copies the Flash movie message field
|
||||
`CATEGORY_ID` verbatim** into it. So the category is chosen by the Scaleform movie.
|
||||
- `FUN_18007e5e0` binds the six store tabs (`FUN_180014580`: `mypacks, points, bronze,
|
||||
silver, gold, special`) to `PANEL_ID` = matching group ordinal, or hides the panel.
|
||||
- Unopened-pack count signals (server, already correct at 0 when empty):
|
||||
`userInfo.unopenedPacks.recoveredPacks` and `/user/credits .unopenedPacks`. The hub
|
||||
`CentralUnclaimedPack` tile (destination `GOTO_STORE_MYPACK`) is gated by this count
|
||||
in the hub model (`model+0x20950`). **Candidate F (a server count gating the STORE's
|
||||
My-Packs resolution) was CONTRADICTED**: the count is correct at 0 yet the store
|
||||
still resolves My Packs, because the decision is movie-side.
|
||||
|
||||
## 2. Desired clean client behavior
|
||||
|
||||
```
|
||||
unopened-pack count == 0:
|
||||
Store defaults to Browse Packs (e.g. a real category such as bronze/gold)
|
||||
My Packs is NOT selected/resolved
|
||||
no synthetic placeholder tile is required from the server
|
||||
unopened-pack count > 0:
|
||||
existing My Packs behavior unchanged
|
||||
```
|
||||
|
||||
## 3. Candidate insertion points (ranked)
|
||||
|
||||
Ranking favors fixing the UX (not merely preventing the crash) and the smallest,
|
||||
lowest-risk change that achieves it.
|
||||
|
||||
### Rank 1 (preferred, best UX) — Scaleform / category-selection layer
|
||||
Prevent the movie from emitting `CATEGORY_ID == mypacks` (and from defaulting the
|
||||
store into My Packs) when the unopened-pack count is 0; default to Browse Packs
|
||||
instead.
|
||||
- **Where:** the FUT Store Scaleform movie / ActionScript (`StoreFront`,
|
||||
`CATEGORY_ID`/`ACTION_GET_PACKLIST`, `GOTO_STORE_MYPACK`), which the packed exe hosts
|
||||
and which reads the hub model (it already knows the count for the
|
||||
`CentralUnclaimedPack` tile).
|
||||
- **Behavior changed:** the store's initial/selected category when empty.
|
||||
- **Scope:** movie asset edit (client-side), no native-code patch.
|
||||
- **Risk:** medium — Scaleform RE/editing is fiddly; must find where the default
|
||||
`CATEGORY_ID` is chosen and gate it on the count without breaking the count>0 path.
|
||||
- **Compatibility:** per-client asset change; does not touch protocol or other clients.
|
||||
- **Fixes UX or just crash?** **UX** — no fake tile, correct default; the crash also
|
||||
disappears because `mypacks` is never resolved when absent.
|
||||
- **Evidence:** `screen+0x290 ← CATEGORY_ID` (`FUN_18007e7f0` case `0x7551`); count
|
||||
already available client-side (hub model / `unopenedPacks`).
|
||||
|
||||
### Rank 2 — Native Store resolver fallback (CardsDLL)
|
||||
Make `FUN_1800147f0`/`FUN_180014420` fall back to a safe category (e.g. list-tiles
|
||||
`N==0`, or the first existing group) when the requested ordinal misses, instead of
|
||||
dereferencing NULL.
|
||||
- **Behavior changed:** category-miss handling for ALL categories, not just mypacks.
|
||||
- **Scope:** small, localized CardsDLL binary patch near `0x180014420`/`0x180014882`.
|
||||
- **Risk:** medium — alters native store behavior globally; could mask other
|
||||
legitimate misses; the movie may still believe it is in My Packs (empty/odd view).
|
||||
- **Compatibility:** binary patch to the shipped DLL (client-side).
|
||||
- **Fixes UX or just crash?** Crash + partial UX (no crash, but the empty-My-Packs
|
||||
view may still be awkward).
|
||||
- **Evidence:** the no-guard deref at `0x180014882`; `FUN_180014420` returns NULL on
|
||||
miss.
|
||||
|
||||
### Rank 3 (cheapest, crash-only) — CardsDLL null guard
|
||||
Insert a null check before the `[RAX+0x48]` dereference in `FUN_1800147f0` (a single
|
||||
`TEST/JZ` around the deref) so a NULL group is skipped/returned safely.
|
||||
- **Behavior changed:** only the crash path.
|
||||
- **Scope:** minimal (a few bytes) binary patch at `~0x180014882`.
|
||||
- **Risk:** low — smallest change; but purely crash-prevention. With no `mypacks`
|
||||
group the resulting empty view is unverified (could be a blank/empty-category state).
|
||||
- **Compatibility:** binary patch (client-side).
|
||||
- **Fixes UX or just crash?** Crash only.
|
||||
- **Evidence:** minidump faulting instruction `CardsDLL+0x14882`, `[NULL+0x48]`.
|
||||
|
||||
## 4. Recommended long-term outcome
|
||||
|
||||
Rank 1 (Scaleform default-category gating) is the clean fix: with count 0 the store
|
||||
opens on Browse Packs, no `mypacks` resolution, no fake tile — and the **backend
|
||||
sentinel 65534 can be dropped for patched clients** (the server would simply omit the
|
||||
`mypacks` group when empty, which is safe once the client no longer resolves it).
|
||||
Rank 3 (null guard) is a cheap universal crash-safety net that could ship alongside.
|
||||
Until a client-side fix exists, the backend P2 sentinel remains the required
|
||||
compatibility behavior for unpatched retail clients.
|
||||
|
||||
## 5. Open questions / next research (no execution here)
|
||||
- Locate the Store movie's default/initial `CATEGORY_ID` selection and confirm it can
|
||||
read the unopened count (Rank 1 feasibility).
|
||||
- Confirm, via a guarded-resolver experiment, what the empty-My-Packs view degrades to
|
||||
if the `mypacks` group is simply absent + a null guard is present (Rank 2/3).
|
||||
- Determine whether the Browse-Packs→My-Packs navigation gate (observed with the
|
||||
active sentinel) also resolves under Rank 1.
|
||||
|
||||
---
|
||||
|
||||
# PART II — Native client-fix design (RE-backed, 2026-08-13)
|
||||
|
||||
Investigation-and-design phase (no client binary/movie changed, no backend changed,
|
||||
no new live Store experiment). CardsDLL was re-analysed in Ghidra on `.105`; the
|
||||
in-repo decompiled addresses were reconfirmed against a freshly-built project. Every
|
||||
claim below is labelled **ESTABLISHED** (read from this build's binary / crash dump),
|
||||
**PROPOSED** (design, not yet implemented), or **UNKNOWN**.
|
||||
|
||||
## 6. Binary + environment verification (ESTABLISHED)
|
||||
|
||||
Hashes re-verified on `.105` (`/mnt/games/FIFA 17/`) — identical to the recorded RE:
|
||||
- `CardsDLL_Win64_retail.dll` SHA-256 `4706a881ae1fc7b5769fd810b25a868d29d2b16a8e65a7513436327ef645573c`,
|
||||
size 3179952, PE `TimeDateStamp` 1497050156 (2017-06-09T23:15:56Z), `SizeOfImage`
|
||||
`0x31d000`, image base `0x180000000` (RE-space). **Unpacked → statically analysable.**
|
||||
- `FIFA17.exe` SHA-256 `29c31cef12b0c3c2a7305220617c7b4fa139ab76b8c857851bdbe88987962899`,
|
||||
size 224639408, **Denuvo-packed** → the Scaleform/StoreFront ActionScript that
|
||||
*decides* to emit `CATEGORY_ID` is NOT statically readable. This is why a
|
||||
pure-Scaleform edit (old "Rank 1") is not the practical vehicle; the fix is taken
|
||||
at the readable native boundary in CardsDLL instead.
|
||||
- Ghidra project rebuilt at `.105:/tmp/ghidra_fut/cardsdll` (headless import+analysis
|
||||
succeeded). Tooling: `fifa17-recon/tools/ghidra_env.py` run under `~/.venv`
|
||||
(`PYTHONPATH=/opt/ghidra/Ghidra/Features/PyGhidra/pypkg/src:/usr/lib/python3.14/site-packages`;
|
||||
`jpype1` reinstalled offline from pip cache). RVAs below = static VA − `0x180000000`.
|
||||
|
||||
## 7. Category-selection path (ESTABLISHED — decompiled this build)
|
||||
|
||||
Store message dispatch `FUN_18007d880` (RVA `0x7d880`) routes Flash message ids:
|
||||
`0x753f → FUN_18007dab0` (render), `0x278a → FUN_18007df60` (publish category ids),
|
||||
and the input handler `FUN_18007e7f0` (RVA `0x7e7f0`) case **`0x7551`** copies the
|
||||
movie field `CATEGORY_ID` verbatim into `screen+0x290` (the only non-ctor writer;
|
||||
ctor `FUN_18007d1a0` writes 0).
|
||||
|
||||
**Store render `FUN_18007dab0` (RVA `0x7dab0`), decompiled verbatim, is the decision
|
||||
point:**
|
||||
```c
|
||||
iVar1 = *(int *)(param_1 + 0x290); // requested CATEGORY_ID (screen+0x290)
|
||||
iVar6 = FUN_180014580(store, 1); // the *points* category id (see tab map)
|
||||
if (iVar1 == iVar6) { // requested category is POINTS (real-money)
|
||||
if (region_check() == 0) { post "REGION_MISMATCH"; return; }
|
||||
if (FUN_180014de0(store) != 0) return; // points group present → handled
|
||||
FUN_180014b60(store, dp); // else points render
|
||||
} else {
|
||||
FUN_1800147f0(store, iVar1, dp, 0, 0); // EVERY other category, incl. My Packs
|
||||
}
|
||||
```
|
||||
- `param_1` (RCX) = the store-screen object; `+0x290` is the requested category.
|
||||
- **Tab→id map `FUN_180014580(store, n)` (RVA `0x14580`): `0=mypacks, 1=points,
|
||||
2=bronze, 3=silver, 4=gold, 5=special`.** Each returns the group's **1-based
|
||||
ordinal** (via caption compare `FUN_180014380`) or **`-1`** if that group is absent.
|
||||
So category ids are DYNAMIC ordinals, not fixed constants. The tab publisher
|
||||
`FUN_18007df60` pushes `MYPACK_/BRONZE_/…_CATEGORY_ID` to the movie from these
|
||||
lookups; the movie echoes one back as `CATEGORY_ID`.
|
||||
- The **points** tab is the only one special-cased (commerce/region gate). **My Packs
|
||||
is NOT special-cased — it falls into the `else` and is resolved by
|
||||
`FUN_1800147f0`.**
|
||||
|
||||
**Resolver `FUN_1800147f0` (RVA `0x147f0`) — the crash (ESTABLISHED, instruction
|
||||
level):**
|
||||
```
|
||||
0x14856: 85 ff TEST EDI,EDI ; EDI = category ordinal (param_2)
|
||||
0x14858: 75 0f JNZ 0x14869 ; ==0 → list-all (Browse), else resolve
|
||||
0x1485a: … CALL 0x14610 ; FUN_180014610 list ALL group tiles
|
||||
0x14867: eb 29 JMP 0x14892
|
||||
0x14869: 8b d7 MOV EDX,EDI
|
||||
0x1486b: e8 … CALL 0x14420 ; FUN_180014420(store, ordinal) → RAX (group|NULL)
|
||||
0x14870: 48 8d 50 40 LEA RDX,[RAX + 0x40] ; RDX = group+0x40 (=0x40 when RAX=NULL)
|
||||
0x14878: 48 3b c2 CMP RAX,RDX
|
||||
0x1487b: 74 15 JZ 0x14892
|
||||
0x14882: 4c 8b 42 08 MOV R8,[RDX + 0x8] ; <-- FAULT: read [0x40+0x8]=0x48 when NULL
|
||||
0x14886: 48 8b 12 MOV RDX,[RDX] ; [0x40]
|
||||
```
|
||||
`FUN_180014420` (RVA `0x14420`) exact-matches `group+0x00` (ordinal), stride `0x108`,
|
||||
**returns NULL on a miss, with no guard in the caller** → faulting read of VA `0x48`
|
||||
at `0x180014882`. This is byte-for-byte the Experiment-B minidump
|
||||
(`0xC0000005` READ `0x48` at `CardsDLL+0x14882`).
|
||||
- `param_2 == 0` → `FUN_180014610` lists **all** group tiles = the safe "Browse Packs"
|
||||
view. `param_2 == existing ordinal` → resolves. `param_2 == a non-existent ordinal`
|
||||
(e.g. `-1`, which `MYPACK_CATEGORY_ID` becomes when the group is absent) → NULL → crash.
|
||||
|
||||
**Why it crashes with zero packs (ESTABLISHED):** with `unopenedPackIds==[]` and no
|
||||
sentinel, no `mypacks` group exists, so `FUN_180014580(store,0) = -1`,
|
||||
`MYPACK_CATEGORY_ID = -1`, the movie still selects My Packs and echoes `CATEGORY_ID =
|
||||
-1`, and `FUN_1800147f0(store, -1, …)` → `FUN_180014420(-1)=NULL` → crash. The active
|
||||
sentinel (65534) works only because it makes a real `mypacks` ordinal exist to resolve.
|
||||
|
||||
## 8. Zero-pack state client-side (ESTABLISHED)
|
||||
|
||||
The client already holds the correct unopened-pack count in a **data-manager
|
||||
singleton** (the same one the store resolver uses):
|
||||
- Obtain: `seed = FUN_1800d7170()` then `FUN_180009c80(&p, seed)` → `p` (release with
|
||||
`p->vtbl[0x08](p)`). This exact accessor already runs inside `FUN_180014420` and
|
||||
`FUN_1800147f0`, so any store-category hook can reach it.
|
||||
- **Read count: `p->vtbl[0x4d8](p)` → int. Write: `p->vtbl[0x4e0](p, n)`.** Confirmed
|
||||
in `FUN_180019780`, which reads slot `0x4d8`, adds the number of set booleans in a
|
||||
pack response, and writes slot `0x4e0` (it also fetches `FutGetPurchasedItems`).
|
||||
- Representation: plain `int`; **0 = no unopened packs**, `>0` = count. Lifetime: the
|
||||
singleton persists for the session; updated on pack acquire/open.
|
||||
- No dedicated "hasUnopenedPacks" boolean helper was found; `count != 0` is the
|
||||
predicate. (The hub `CentralUnclaimedPack` tile is gated by this same count via
|
||||
`model+0x20950`, written by `FUN_18010cdc0`/`FUN_18011e120` — the hub mirror, not the
|
||||
store gate.)
|
||||
|
||||
## 9. Implementation vehicle (ESTABLISHED — reuse, do not build a new loader)
|
||||
|
||||
OpenFUT **already ships a client hook framework**: `openfut-launcher/openfut-hook`
|
||||
(`crate-type=["cdylib"]`) builds **`version.dll`**, a proxy DLL placed in the game dir
|
||||
(`/mnt/games/FIFA 17/version.dll`, present & active; log `~/.wine/drive_c/openfut_hook.log`).
|
||||
- Load path: Wine/Windows loads `version.dll` from the app dir at process start →
|
||||
`DllMain(DLL_PROCESS_ATTACH)` → `install_hooks()`.
|
||||
- Existing hooks (`lib.rs`): `getaddrinfo` (IAT via `iat::resolve`), `connect`
|
||||
(inline detour), `WSAConnect`, `WSAIoctl`/ConnectEx, origin_spy registry/mutex,
|
||||
crypt32 `CertVerifyCertificateChainPolicy`, **and in-memory byte-patching of the
|
||||
loaded (packed) main exe + EAWebKit** (`ssl_patch`: `GetModuleHandleA` → scan for a
|
||||
unique prologue → `VirtualProtect`+`copy_nonoverlapping`).
|
||||
- Inline-hook primitive (`connect_hook`): `write_hook(target, dest)` lays a 14-byte
|
||||
`FF 25 00000000 <abs64>` JMP; `restore_original` restores saved bytes
|
||||
(unhook → call real → rehook, avoiding trampoline relocation).
|
||||
- Config: `openfut.cfg` beside the DLL (`host`/ports today; a `store_mypacks_fix`
|
||||
flag would be added there).
|
||||
- **Suitability for the Store fix: direct.** The DLL is in-process with full access
|
||||
to the loaded `CardsDLL_Win64_retail.dll`; the store fix is a NEW module
|
||||
(`store_hook.rs`) installed from `install_hooks`, reusing the `ssl_patch`
|
||||
signature-scan and the `connect_hook` inline-detour patterns. No new loader, no ASI,
|
||||
no separate injector.
|
||||
|
||||
## 10. Three strategies re-evaluated against the RE (Task 4)
|
||||
|
||||
### A. Category-selection redirect — **PREFERRED** (best UX, native, targeted)
|
||||
Hook `FUN_18007dab0` (RVA `0x7dab0`) at entry; before the original runs, redirect a
|
||||
zero-pack My-Packs request to Browse Packs:
|
||||
```
|
||||
cat = *(int*)(store + 0x290)
|
||||
mypacks_id = FUN_180014580(store, 0) // -1 when the group is absent
|
||||
if (cat == mypacks_id) { // movie asked for My Packs (incl. cat==-1==id)
|
||||
if (unopened_count() == 0) // singleton vtbl[0x4d8]
|
||||
*(int*)(store + 0x290) = 0; // 0 = FUN_180014610 list-all = Browse Packs
|
||||
}
|
||||
// then call the original FUN_18007dab0(store)
|
||||
```
|
||||
- Uses the real count? **Yes** (singleton `vtbl[0x4d8]`). Removes the fake 65534 tile?
|
||||
**Yes** (server can omit the group). Removes the click-dialog? **Yes** (no placeholder
|
||||
to click). Removes the Browse→My-Packs nav gate? **Yes** (store lands on Browse, not
|
||||
an empty My-Packs). Preserves count>0? **Yes** (`cat==mypacks_id` with count>0 is left
|
||||
untouched → normal My Packs). Affects other categories? **No** (`cat!=mypacks_id`
|
||||
path is unmodified; points/bronze/… unchanged).
|
||||
- Prevents the crash as a side effect (My Packs is never resolved when its group is
|
||||
absent). This is the old "Rank 1" INTENT, implemented at the readable native boundary
|
||||
instead of in packed Scaleform.
|
||||
|
||||
### B. Resolver fallback — acceptable safety net, less targeted
|
||||
In `FUN_1800147f0` (or right after the `CALL 0x14420` at RVA `0x1486b`): if the
|
||||
resolved group is NULL, fall back to list-all (`param_2=0`) instead of dereferencing.
|
||||
- Prevents crash? **Yes.** Fixes default nav / removes fake tile? **Partially** — the
|
||||
movie still believes it is in My Packs, so the view may be an empty/odd My-Packs
|
||||
rather than a clean Browse. Leaves other lookups unchanged? **It changes miss-handling
|
||||
for ALL categories** — a generic NULL fallback that could mask a genuine
|
||||
missing-category protocol bug. Higher risk than A for that reason; keep as a
|
||||
belt-and-braces guard, not the primary UX fix. The resolver does NOT know *why*
|
||||
`mypacks` is missing, which is exactly the concern the task flags.
|
||||
|
||||
### C. Null-guard only — weakest (crash-only)
|
||||
Insert `TEST RAX,RAX; JZ 0x14892` immediately after `CALL 0x14420` (RVA `0x1486b`),
|
||||
before `LEA RDX,[RAX+0x40]`. Needs a trampoline (no inline slack).
|
||||
- Converts the crash into whatever an empty tile-vector renders (unverified; likely a
|
||||
blank/empty category). Does **not** remove the fake tile or fix the default category;
|
||||
the sentinel would still be needed for acceptable UX. Verified as expected-weakest.
|
||||
|
||||
## 11. Concrete hook target for strategy A (Task 6, PROPOSED)
|
||||
```
|
||||
module: CardsDLL_Win64_retail.dll (GetModuleHandleA)
|
||||
function: FUN_18007dab0 (store render / message 0x753f)
|
||||
RVA: 0x7dab0 (static VA 0x18007dab0)
|
||||
calling conv: Microsoft x64 fastcall; single arg store-screen ptr in RCX
|
||||
screen offset: store+0x290 = requested CATEGORY_ID (int)
|
||||
helpers to call: FUN_180014580 (RVA 0x14580) tab→ordinal, arg0=RCX store, arg1=EDX index(0=mypacks)
|
||||
count singleton: FUN_1800d7170 (0xd7370-seed) + FUN_180009c80 (0x9c80), read vtbl[0x4d8]
|
||||
redirect target: set store+0x290 = 0 (FUN_180014610 list-all → Browse Packs)
|
||||
original behavior: zero packs → resolves absent mypacks ordinal → FUN_180014420 NULL → crash at 0x14882
|
||||
desired behavior: zero packs + mypacks requested → store+0x290 forced to 0 → Browse Packs; no crash/dialog/tile
|
||||
```
|
||||
Hook mechanics (reuse `connect_hook`): lay a 14-byte `FF 25` JMP at `base+0x7dab0` to a
|
||||
Rust `hooked_store_render(store)`; inside: apply the redirect, unhook, call real
|
||||
`FUN_18007dab0(store)`, rehook, return its value. Intercepting only the entry means the
|
||||
minimum interception is the 14 JMP bytes; the first instructions of `FUN_18007dab0`
|
||||
(`MOV RAX,RSP; MOV [RAX+8],RCX; PUSH …`) are a standard prologue safe to save/restore.
|
||||
Alt insertion point (earlier): `FUN_18007e7f0` case `0x7551`, where `CATEGORY_ID` is
|
||||
written to `screen+0x290` — redirect there instead of at render. Entry-hook of
|
||||
`FUN_18007dab0` is preferred (single, well-typed arg; runs once per store render).
|
||||
|
||||
Thread/context: the store screen runs on the client's UI/update thread; the hook reads
|
||||
one int and (rarely) writes one int on the same object the callee immediately reads —
|
||||
no new synchronization needed. Called for categories other than My Packs? The FUNCTION
|
||||
is, but the redirect body only fires when `cat==mypacks_id`, so other tabs are
|
||||
untouched.
|
||||
|
||||
## 12. Version / build safety (Task 7, PROPOSED)
|
||||
FIFA17-specific compat code MUST validate the client before hooking, and MUST no-op on
|
||||
any other build (the same `version.dll` is also used for FIFA23):
|
||||
1. **Module gate:** only proceed if `GetModuleHandleA("CardsDLL_Win64_retail.dll")`
|
||||
resolves (FIFA23 has no such module → auto-skip).
|
||||
2. **Build gate (both, belt-and-braces):**
|
||||
- Exact hash/PE gate: on-disk SHA-256 == `4706a881…`, or PE `SizeOfImage==0x31d000`
|
||||
&& `TimeDateStamp==1497050156` (cheap in-memory check).
|
||||
- Signature scan + validation: locate `FUN_18007dab0` by a unique prologue/byte
|
||||
window rather than trusting the RVA, and assert the known bytes at the branch
|
||||
(`85 ff 75 0f` region) and at the resolver `CALL 0x14420` site match before
|
||||
installing. Recommend **both**: hash to reject the wrong game fast, signature to
|
||||
confirm the exact patch site.
|
||||
3. **Failure behavior:** any check fails (unknown/updated build) → **do NOT patch**,
|
||||
log, and leave the **backend P2 active-sentinel (65534) as the fallback**. Never
|
||||
patch or crash an unrecognised build.
|
||||
|
||||
## 13. First controlled client experiment (Task 8, PROPOSED — not executed here)
|
||||
Goal: prove a patched client sends zero-pack Store entry to Browse Packs with **no**
|
||||
active placeholder.
|
||||
- Build `openfut-hook` with strategy-A `store_hook`, gated behind `openfut.cfg`
|
||||
`store_mypacks_fix=1` (opt-in; default off preserves today's behavior).
|
||||
- Test profile: `unopenedPackIds == []`.
|
||||
- Sequence (each variable changed alone; operator drives FIFA; read-only capture):
|
||||
1. Deploy patched `version.dll`; confirm `openfut_hook.log` shows the store hook
|
||||
installed + build gate PASSED.
|
||||
2. **Backend test mode (LATER, separately authorized — NOT in this task):** switch the
|
||||
backend to *empty-no-sentinel* (the Exp-B config that crashed the UNPATCHED client)
|
||||
so the patched client must handle a genuinely-absent `mypacks` group.
|
||||
3. Operator opens Store. **Predicted (patched + zero packs + no sentinel):** Store
|
||||
opens, defaults to Browse Packs, no `mypacks` resolve, **no crash, no dialog, no
|
||||
fake tile**.
|
||||
4. Set `unopenedPackIds=[70]`; reopen. **Predicted:** My Packs works normally
|
||||
(hook body skipped because count>0).
|
||||
5. Revert backend to the active sentinel.
|
||||
- **Backend change eventually required for this experiment: YES** — a controlled
|
||||
empty-no-sentinel test mode to force the absent group. It is NOT performed in this
|
||||
phase and MUST be separately authorized (same experiment discipline: patch the
|
||||
container copy, capture, revert, restart; never synthesize a client request).
|
||||
- **Client rollback:** flip `store_mypacks_fix=0` (hook not installed) or restore the
|
||||
original `version.dll`; the game reverts to depending on the backend sentinel. No FIFA
|
||||
binaries/movies/config are modified on disk — the hook is in-memory only, so rollback
|
||||
is a file/flag swap.
|
||||
|
||||
## 14. Interaction with the backend 65534 fallback (ESTABLISHED + PROPOSED)
|
||||
- **Keep the backend sentinel deployed** until strategy A is implemented AND verified.
|
||||
It remains the required behavior for unpatched retail clients and for any client whose
|
||||
build gate fails.
|
||||
- Once strategy A is verified, the server MAY, **for patched clients only**, omit the
|
||||
`mypacks` group when empty (the safe representation the client will then handle) —
|
||||
but only behind explicit detection/opt-in; do NOT drop the sentinel globally, since
|
||||
unpatched clients still crash without it.
|
||||
|
||||
## 15. ESTABLISHED / PROPOSED / UNKNOWN summary
|
||||
- **ESTABLISHED:** binary hashes/build; the full native category path and addresses
|
||||
(`FUN_18007d880/18007dab0/18007e7f0/1800147f0/180014420/180014580/180014610`); the
|
||||
instruction-level crash (`0x14882`, `[NULL+0x48]`); tab→ordinal map; that My Packs is
|
||||
not special-cased and funnels through `FUN_1800147f0`; the unopened-count singleton
|
||||
and its `vtbl[0x4d8]/[0x4e0]` accessors, reachable from store code; the
|
||||
`openfut-hook`/`version.dll` vehicle and its hook/patch primitives.
|
||||
- **PROPOSED (not implemented):** the strategy-A entry hook and its redirect logic; the
|
||||
build-guard scheme; the opt-in config flag; the first experiment and its backend
|
||||
test-mode requirement; the per-patched-client server relaxation.
|
||||
- **UNKNOWN:** exactly why the packed Scaleform movie selects My Packs on store open
|
||||
(Denuvo-packed, unread) — not needed for strategy A, which intercepts the native
|
||||
result; the precise rendered appearance of `category==0` list-all in this empty
|
||||
configuration (to be observed in the experiment); whether any non-store path also
|
||||
drives `screen+0x290` to a My-Packs ordinal (none found; `FUN_18007e7f0` case `0x7551`
|
||||
and the ctor are the only writers).
|
||||
|
||||
---
|
||||
|
||||
# PART III — Final no-sentinel resolver experiment (2026-08-13) — RESULT F3 (CRASH), CONFOUNDED
|
||||
|
||||
Vehicle change: the resolver guard was implemented as an **`autopatch.py` memory patch**
|
||||
(the live FIFA-17 client-patch mechanism), NOT the `version.dll` proxy — Proton loads its
|
||||
builtin `version.dll`, so the earlier `store_hook`/`version.dll` prototype was inert and
|
||||
has been rolled back. Guard: at CardsDLL `0x180014858`, `JNZ 0x14869` (`75 0f`) →
|
||||
`JG 0x14869` (`7f 0f`), orig-verified; routes category `< 0` (and `== 0`) to the safe
|
||||
list-all/Browse path (`FUN_180014610`), category `> 0` to the existing resolver.
|
||||
`TEST EDI,EDI` at `0x180014856` is the flag source (OF cleared ⇒ `JG` = signed `> 0`).
|
||||
|
||||
## Setup (verified)
|
||||
- CLIENT: guard active/enforced — live bytes `85 ff 7f 0f` at `0x180014856` (FIFA pid 547843,
|
||||
autopatch pid 547621; log `ENFORCED guarded store patch @ … (JNZ->JG)`, orig `75 0f` matched).
|
||||
- BACKEND: sentinel 65534 suppressed by a one-line `if not owned_ids:` → `if False:` in the
|
||||
container copy only (committed source `f42279f` untouched; backup `/tmp/utas_server.EXP_ORIG.py`).
|
||||
Genuine `GET /store/purchasegroup` (02:44:39Z) → ids `[1,5,6,7]`, **no 65534, no mypacks group**,
|
||||
normal packs unchanged (evidence: `docs/evidence/store_purchasegroup_capture_client_guard_no_sentinel_2026-08-13.json`).
|
||||
- PROFILE: `unopenedPackIds=[]`, coins 29,876,776, sha `39bb3e83…` — unchanged throughout.
|
||||
|
||||
## Result — F3 (CRASH)
|
||||
Minidump `CrashDump_2026.08.12_20.44.40.302.dmp` (preserved `/tmp/expF_crash.dmp`, sha `4dcb0cb7…`):
|
||||
`0xC0000005` READ of VA `0x48` at `ExceptionAddress 0x6ffffc224882` → **RE `0x180014882`** —
|
||||
the **identical** resolver crash instruction as Experiment B (`FUN_1800147f0`,
|
||||
`MOV R8,[RDX+0x8]` with the group ptr NULL).
|
||||
|
||||
**Mechanism (decisive):** `0x14882` lives in the *resolve* branch, which the guard's `JG`
|
||||
reaches **only when category `> 0`**. Since the guard was verified in place, the client
|
||||
presented a **positive** My-Packs ordinal that no longer resolves (no mypacks group) →
|
||||
`FUN_180014420` returned NULL → crash. The guard's design assumption — *absent mypacks ⇒
|
||||
category `-1`* — did NOT hold on this path.
|
||||
|
||||
## Confound (uncontrolled variable)
|
||||
FIFA was **not relaunched** after the backend flipped to no-sentinel; the client carried
|
||||
**stale store/tab state** from the sentinel-present safe stage, where the mypacks group
|
||||
existed at a *positive* ordinal `N` (`MYPACK_CATEGORY_ID = N`). Reopening the Store reused
|
||||
that stale positive ordinal rather than the `-1` a **fresh** launch publishes
|
||||
(`FUN_18007df60 → FUN_180014580(store,0) = -1` when absent). So the intended clean A/B (client
|
||||
only ever sees the no-sentinel response) was not achieved — the category that reached the
|
||||
resolver was a stale `>0`, exactly the case the negative-only guard does not divert.
|
||||
|
||||
## Conclusion / strategy status
|
||||
- **The guard as-written does NOT handle a positive, now-invalid My-Packs ordinal** — proven
|
||||
by this crash. Diverting only `category < 0` is insufficient when the client presents a
|
||||
stale/positive ordinal for an absent group.
|
||||
- **Not falsified for the fresh-client case.** Whether a fresh no-sentinel launch presents
|
||||
`-1` (guard diverts → Browse, no crash) or still a positive ordinal is **UNKNOWN** and needs
|
||||
a **clean re-test**: launch FIFA fresh with the backend already in no-sentinel mode so the
|
||||
client never sees a mypacks group. That is the proper equivalent of Experiment B.
|
||||
- **Candidate stronger guard** (design only, not implemented): divert to list-all when the
|
||||
resolved group is NULL for *any* category (guard `FUN_180014420`'s NULL return at the
|
||||
`0x14870`/`0x14882` site), not merely when `category < 0`. This covers the positive-invalid
|
||||
ordinal too, at the cost of being a generic miss-fallback (the higher-risk Rank-2 behavior).
|
||||
Do NOT implement without authorization and a clean re-test first.
|
||||
|
||||
**Strategy A / resolver guard status: NOT PROVEN.** Crash-guard installs and is build-validated
|
||||
and dormant-safe with the sentinel present, but the first no-sentinel test CRASHED at the
|
||||
resolver via a positive stale ordinal (confounded by no relaunch). Backend P2 active-sentinel
|
||||
was restored immediately (mandatory rollback; source `f416e71e…`, sentinel `state=active`),
|
||||
and remains the production safety net. Guard left in `autopatch.py` (dormant) pending the
|
||||
clean re-test decision; `autopatch.py.pre-storeguard.bak` available to remove it.
|
||||
|
||||
---
|
||||
|
||||
# PART IV — Fresh-process no-sentinel retest (2026-08-13) — RESULT R1 (SUCCESS)
|
||||
|
||||
Corrects PART III's confound. This time the mandatory ordering was enforced: the backend
|
||||
entered no-sentinel mode **while FIFA was closed**, then FIFA launched **fresh** (new pid,
|
||||
new autopatch) so the process never saw a sentinel-present Store response.
|
||||
|
||||
## Setup (verified, clean A/B)
|
||||
- BACKEND set no-sentinel at 02:55:09Z with FIFA down; genuine `GET /store/purchasegroup`
|
||||
(02:58:45Z) served to the fresh client = ids `[1,5,6,7]`, **no 65534, no mypacks group**,
|
||||
packs 1/5/6/7 present. This body is **byte-identical** to the PART III (F3) no-sentinel
|
||||
capture — the ONLY changed variable vs F3 is the client process lifetime.
|
||||
Evidence: `docs/evidence/store_purchasegroup_capture_freshretest_no_sentinel_2026-08-13.json`.
|
||||
- CLIENT: NEW FIFA pid 553220, NEW autopatch pid 552999; guard ENFORCED (orig `75 0f`
|
||||
matched → `85 ff 7f 0f` = `TEST EDI,EDI; JG`). Process never saw a sentinel response
|
||||
(0 purchasegroup responses containing 65534 after the no-sentinel restart).
|
||||
- PROFILE unchanged throughout (`39bb3e83…`, `[]`, coins 29,876,776).
|
||||
|
||||
## Result — R1 (operator-observed)
|
||||
- **No crash** (FIFA 553220 alive after the test; no new minidump), **no dialog**, **Store
|
||||
stays open**, opens on **Browse Packs**, Bronze/Gold/Special packs visible and navigable.
|
||||
- Cosmetic-only imperfections (pre-existing, NOT caused by the guard): the six-tab bar is
|
||||
unbound (no tabs), packs render without cover art, and tiles show "0 items". These match
|
||||
the known store tab-bind / list-all rendering quirks (`plan-2026-08-05-store-subsystem.md`
|
||||
§2.1) and are independent of the resolver guard.
|
||||
|
||||
## Causal conclusion (decisive A/B)
|
||||
```
|
||||
server response (no sentinel, no mypacks group) == byte-identical across F3 and R1
|
||||
client original JNZ + this response -> CRASH 0x180014882 (Experiment B)
|
||||
client JG (stale positive ordinal) -> CRASH 0x180014882 (PART III F3, contaminated)
|
||||
client JG (FRESH, category = -1) -> NO CRASH, Browse Packs (PART IV R1) ✅
|
||||
```
|
||||
A **fresh** client publishes `MYPACK_CATEGORY_ID = FUN_180014580(store,0) = -1` for the absent
|
||||
group; the movie echoes `-1`; `TEST EDI,EDI; JG` does **not** take the resolve branch, so the
|
||||
client runs the list-all/Browse path (`FUN_180014610`) — no `FUN_180014420(NULL)` deref, no
|
||||
crash. **PART III's F3 is confirmed as stale-positive-ordinal contamination** (FIFA not
|
||||
relaunched across the sentinel→no-sentinel flip), not a guard failure.
|
||||
|
||||
## Strategy status
|
||||
**Strategy A / resolver guard: PROVEN ON THE TESTED FIFA 17 BUILD** (CardsDLL
|
||||
`4706a881…`) for the clean process-lifetime case — it safely routes the absent My-Packs
|
||||
category to Browse Packs with no crash and no dialog, needing **no** backend sentinel. Scope
|
||||
caveats: (1) tested build only; (2) the negative-only guard does NOT cover a stale/positive
|
||||
invalid ordinal (PART III) — only arises if the client's Store state predates a sentinel→
|
||||
no-sentinel change within one process, which does not happen on a normal launch; a NULL-return
|
||||
guard at `FUN_180014420` would additionally cover that, deferred/not implemented; (3) UX still
|
||||
has the pre-existing no-tabs/no-art/"0 items" cosmetics.
|
||||
|
||||
Backend P2 active-sentinel was restored immediately after capture (mandatory rollback; source
|
||||
`f416e71e…`, sentinel `state=active`) and **remains production default**. The clean UX is only
|
||||
safe to serve when the server knows the client is patched — see PART II §12 rollout options
|
||||
(recommend B: suppress the sentinel only when client patch-capability is known; keep the
|
||||
sentinel universal by default). Guard retained in `autopatch.py` (dormant with the sentinel).
|
||||
|
||||
## INVARIANT — empty-My-Packs capability MUST be session-stable
|
||||
|
||||
F3 vs R1 establish a hard operational invariant for any deployment (sentinel or client
|
||||
guard): **the server MUST NOT switch a running FIFA client between sentinel-present and
|
||||
sentinel-absent for the My Packs group within a single FIFA process lifetime.**
|
||||
|
||||
Rationale: the client resolves and caches the My-Packs group **ordinal** (positive when a
|
||||
group — real or sentinel — is present; `-1` when absent) from the `purchasegroup` response
|
||||
seen at Store-subsystem init. The resolver guard only reclassifies the ordinal *sign*
|
||||
(`≤0` → Browse). If a client that already cached a **positive** ordinal later receives a
|
||||
no-sentinel topology, the stale positive ordinal still takes the resolve branch and
|
||||
`FUN_180014420` returns NULL → crash at `0x180014882` (exactly F3). A **fresh** process that
|
||||
only ever sees the no-sentinel topology caches `-1` and is routed to Browse safely (R1).
|
||||
|
||||
Practical rules:
|
||||
- Choose the My-Packs representation (sentinel-present vs sentinel-absent) **before** a client
|
||||
starts its session, and hold it for that session.
|
||||
- The future patch-capability handshake (PART II §12) MUST therefore be decided at
|
||||
login/session start, not toggled mid-session.
|
||||
- A NULL-return guard at `FUN_180014420` (deferred) is the only thing that would make a
|
||||
mid-session flip crash-safe; until then, session stability is mandatory.
|
||||
@@ -0,0 +1,358 @@
|
||||
# FIFA 17 — verified patched-client capability negotiation
|
||||
|
||||
Goal: let the FIFA 17 backend suppress the synthetic My-Packs sentinel (id 65534)
|
||||
**only when the current FIFA process has positively verified that the CardsDLL
|
||||
resolver guard is active** (JNZ→JG at RVA `0x14858`). Unpatched / unsupported /
|
||||
unknown / failed-patch clients keep receiving the existing P2 active sentinel.
|
||||
|
||||
Core principle: **the capability is not "this launcher supports the patch"; it is
|
||||
"the resolver guard was verified in *this particular FIFA process*."**
|
||||
|
||||
This document is the design + the cross-component contract. It is deliberately
|
||||
additive: the P2 active-sentinel path (`docs/evidence/FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md`)
|
||||
remains the default and the universal fallback.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture inventory (as-built, verified by reading the code)
|
||||
|
||||
Data flow today (launch of one FIFA process):
|
||||
|
||||
```
|
||||
LauncherApp::launch_game (openfut-launcher/src/app.rs:450)
|
||||
-> account_sync::sync POST /openfut/account/sync (:8099) [REQUIRED; launch is gated on it]
|
||||
-> ensure_local_services() spawn LSX, then autopatch.py --launcher-pid <launcher_pid>
|
||||
-> game_launch::launch umu-run FIFA17.exe (grandchild; launcher never learns FIFA PID)
|
||||
FIFA process
|
||||
-> autopatch.py self-discovers FIFA by comm=='FIFA17.exe'; patches /proc/<pid>/mem each tick
|
||||
-> FIFA -> backend POST /ut/auth (login) ; GET /store/purchasegroup ; ... (:8099)
|
||||
```
|
||||
|
||||
Facts that shape the design:
|
||||
|
||||
- **Launcher ↔ autopatch IPC = one-way stdout only.** `local_services::spawn`
|
||||
(openfut-launcher/src/local_services.rs:279-296) pipes autopatch stdout/stderr
|
||||
into the launcher `LogBuffer` line-by-line as `[autopatch] <line>`. There is no
|
||||
socket / named pipe / status-file readback. `--launcher-pid` is the *launcher's*
|
||||
own pid (local_services.rs:66), used for liveness, not to identify FIFA.
|
||||
- **Launcher ↔ backend = exactly one control call:** `account_sync::sync`
|
||||
(openfut-launcher/src/account_sync.rs:43) — a tiny stdlib-HTTP `POST
|
||||
/openfut/account/sync` on `openfut_account_sync_port` (default 8099), sent once
|
||||
per launch, *before* FIFA starts, and **launch is blocked unless it succeeds**
|
||||
(utas_server.py:1204). This is the reliable per-FIFA-process session boundary.
|
||||
- **Backend is single-account, stateless-per-request, threaded.** `SID` is a fixed
|
||||
module constant shared by all clients (utas_server.py:32); account identity is one
|
||||
global `ACCOUNT` singleton. There is **no per-session identity** in requests. The
|
||||
only per-connection discriminator available at every handler is
|
||||
`self.client_address[0]` (peer IP), currently unused. Server is
|
||||
`ThreadingHTTPServer` (utas_server.py:3784); module is import-safe (server under
|
||||
`if __name__ == "__main__"`).
|
||||
- **No bridge/proxy in the FIFA-17 path.** FIFA reaches the Python backend's
|
||||
published `:8099` directly (client-side DNAT/hosts redirect); the openfut-bridge is
|
||||
legacy FIFA-23. Docker's iptables DNAT preserves the source IP for external LAN
|
||||
clients. The launcher and FIFA run on the **same** client machine, so the backend
|
||||
observes them under the **same** peer IP regardless of NAT.
|
||||
|
||||
## 2. Capability transport — options and choice
|
||||
|
||||
Ranked against the as-built architecture:
|
||||
|
||||
**autopatch → launcher (chosen: structured stdout line).**
|
||||
1. **Structured stdout line (CHOSEN).** Reuses the existing one-way pipe the
|
||||
launcher already reads. It is *live* (only the current autopatch child's stdout),
|
||||
inherently child-bound, and carries **zero stale-file risk** — a previous
|
||||
launch's capability cannot leak because nothing is persisted. Smallest possible
|
||||
change. Format is a machine-readable token (§4).
|
||||
2. Status file in `$XDG_RUNTIME_DIR` keyed by launcher-pid+FIFA-pid+version+timestamp
|
||||
— works but needs explicit staleness handling and cleanup; more moving parts.
|
||||
3. Unix-domain socket — most capable but overkill; there is no bidirectional need.
|
||||
|
||||
**launcher → backend (chosen: sibling HTTP endpoint on the account-sync port).**
|
||||
- A. **Existing session-init channel (CHOSEN).** Add `POST /openfut/fifa17/capability`
|
||||
next to the existing `/openfut/account/sync` (same port 8099, same tiny stdlib-HTTP
|
||||
client). It cannot ride *inside* account_sync because the capability is only known
|
||||
*after* autopatch verifies (which happens after account_sync + FIFA start), so it is
|
||||
a separate, later call — but on the same proven transport.
|
||||
- B. Blaze/login metadata — rejected: no OpenFUT-owned field is available without
|
||||
risking a field FIFA depends on, and Blaze runs in a separate responder.
|
||||
- C. New local IPC + backend side-channel — unnecessary; A already exists.
|
||||
- D. Server-wide "assume patched" config — dev/testing fallback only; cannot
|
||||
distinguish patched vs unpatched clients, so never the production mechanism.
|
||||
|
||||
## 3. The capability (name + version + VERIFIED semantics)
|
||||
|
||||
- Name: **`fifa17.empty_mypacks_resolver`**, integer version, current **`1`**.
|
||||
- **VERIFIED (v1) means, for THIS FIFA process:** the CardsDLL tested build was
|
||||
recognised AND the live bytes at RVA `0x14858` are `7f 0f` (`JG`) **after
|
||||
autopatch enforcement** — i.e. `guarded_action` returned `"patch"` (was `75 0f`,
|
||||
written, re-read as `7f 0f`) **or** `"noop"` (already `7f 0f`).
|
||||
- It explicitly does **NOT** mean any of: "autopatch.py contains the guard code",
|
||||
"the launcher build is new enough", or "a config flag is set". The signal
|
||||
represents **observed runtime enforcement on the specific process**, nothing less.
|
||||
|
||||
## 4. autopatch verification state + emitted line
|
||||
|
||||
Per-FIFA-pid guard status (fail-closed; never loosens the existing byte guard):
|
||||
|
||||
| state | meaning |
|
||||
|---|---|
|
||||
| `NOT_ATTEMPTED` | CardsDLL not yet mapped / guard not evaluated for this pid |
|
||||
| `VERIFIED` | live bytes == `7f 0f` after enforcement (from `patch` or `noop`) |
|
||||
| `UNSUPPORTED_BUILD` | live bytes are neither the known original nor patched (`guarded_action` → `skip`) |
|
||||
| `WRITE_FAILED` | `/proc/<pid>/mem` write raised |
|
||||
| `VERIFY_FAILED` | post-write re-read != `7f 0f` |
|
||||
|
||||
Only `VERIFIED` advertises capability. On transition to `VERIFIED`, autopatch emits
|
||||
**once per FIFA pid** on stdout:
|
||||
|
||||
```
|
||||
[store-guard] verified capability fifa17.empty_mypacks_resolver=1 fifa_pid=<pid>
|
||||
```
|
||||
|
||||
Any non-verified terminal state emits an explicit, non-advertising status line, e.g.:
|
||||
|
||||
```
|
||||
[store-guard] guard status=UNSUPPORTED_BUILD fifa_pid=<pid> (no capability advertised)
|
||||
```
|
||||
|
||||
## 5. Launcher per-process capability state
|
||||
|
||||
```rust
|
||||
pub struct Fifa17ClientCapabilities { pub empty_mypacks_resolver: Option<u32> }
|
||||
```
|
||||
|
||||
- Starts **UNKNOWN** (`None`) at each launch.
|
||||
- Becomes `Some(1)` when the launcher parses a valid capability line from the
|
||||
**current** autopatch child's stdout (`parse_capability_line`).
|
||||
- **Discarded** when autopatch stops / FIFA exits / launcher exits / next launch. It
|
||||
is never persisted and never reused for a later FIFA process — staleness is
|
||||
structurally impossible.
|
||||
|
||||
On first `Some(v)`, the launcher registers the capability with the backend (§6) once.
|
||||
|
||||
## 6. Launcher → backend registration + binding
|
||||
|
||||
`POST /openfut/fifa17/capability` (port = `openfut_account_sync_port`, 8099), body:
|
||||
|
||||
```json
|
||||
{"capability":"empty_mypacks_resolver","version":1,"personaId":<id>,"fifaPid":<pid>}
|
||||
```
|
||||
|
||||
- **Binding key = source IP** (`self.client_address[0]`). The registration arrives
|
||||
from the client machine's IP; FIFA's `/store/purchasegroup` requests arrive from
|
||||
the **same** IP (same machine). `personaId`/`fifaPid` are for logging only (the
|
||||
backend is single-account, so persona cannot discriminate clients).
|
||||
- Concurrency: distinct client machines → distinct peer IPs → independent decisions
|
||||
(no global state). Two FIFA processes on **one** machine share an IP — an accepted
|
||||
limitation (the backend is single-account anyway); documented in §Trust.
|
||||
|
||||
## 7. Backend session-stable decision
|
||||
|
||||
Per-IP record (guarded by a lock; threaded server):
|
||||
|
||||
```
|
||||
_FIFA17_STORE[ip] = {"resolver": Option[int], "mode": Option[str]} # mode: None|"sentinel"|"clean-v1"
|
||||
```
|
||||
|
||||
- **Reset (session boundary):** `/openfut/account/sync` from `ip` sets
|
||||
`{resolver: None, mode: None}`. This is the launcher's required per-launch call, so
|
||||
every new FIFA process starts from a clean, unfrozen record — no cross-process leak.
|
||||
- **Register:** `/openfut/fifa17/capability` from `ip` sets `resolver = version`. If
|
||||
`mode` is already frozen, it is logged as late and **ignored for this session**.
|
||||
- **Freeze point = first `/store/purchasegroup`** from `ip` (§9): if `mode is None`,
|
||||
set `mode = "clean-v1"` iff `resolver == 1` else `"sentinel"`, and log once.
|
||||
Thereafter `mode` is immutable for the session.
|
||||
- **Default / fail-closed:** an IP with no record (no account-sync, no capability),
|
||||
an unknown resolver version, a late capability, or a disappeared capability all
|
||||
resolve to (or remain) `"sentinel"`.
|
||||
|
||||
## 8. Freeze point rationale
|
||||
|
||||
Freeze at **first `/store/purchasegroup`**, not at login/account-sync. account-sync
|
||||
fires *before* FIFA starts and *before* autopatch can verify, so freezing there would
|
||||
always be `sentinel`. First Store request is the earliest moment at which a genuine
|
||||
capability can already be registered (autopatch verifies at process start; the user
|
||||
opens the Store later), while still being a single, well-defined topology commit for
|
||||
the session. Once Store topology is served, it must not change (the F3 experiment
|
||||
proved a mid-session flip can leave a stale positive ordinal that crashes even the
|
||||
sign-only guard — see `FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md` PART III/IV and the
|
||||
SESSION-STABLE invariant).
|
||||
|
||||
## 9. Store behaviour (additive switch)
|
||||
|
||||
At `store_catalog`, only the zero-owned-packs branch changes:
|
||||
|
||||
```
|
||||
if not owned_ids:
|
||||
if fifa17_empty_mypacks_mode(client_ip) == "clean-v1":
|
||||
pass # patched client: emit NO mypacks group; guard routes -1 to Browse
|
||||
else:
|
||||
<append active 65534 sentinel exactly as today> # P2 fallback (unchanged)
|
||||
```
|
||||
|
||||
Untouched: real owned-pack rendering, `PACK_CATALOG`, pack 70, normal packs 1/5/6/7,
|
||||
profile state, all store env flags. Default remains sentinel. This lives in the FIFA-17
|
||||
Python backend only — **never** in game-independent OpenFUT Core.
|
||||
|
||||
| client | zero packs | real unopened pack |
|
||||
|---|---|---|
|
||||
| verified v1 | **no sentinel** (clean) | genuine My Packs, no sentinel |
|
||||
| no / unknown capability | **active 65534 sentinel** | genuine My Packs, no sentinel |
|
||||
|
||||
## 10. Trust model (Task 14)
|
||||
|
||||
This is **not** anti-cheat / attestation. OpenFUT assumes the user controls the
|
||||
launcher/client machine and the server is a private preservation environment. The
|
||||
verification exists to prevent *accidents*: a stale capability, an unsupported
|
||||
CardsDLL build, a failed autopatch, the wrong process, or an unpatched client
|
||||
receiving no sentinel and crashing. No signatures / PKI / remote attestation.
|
||||
|
||||
Isolation across *distinct client machines* relies on the backend observing distinct
|
||||
peer IPs (source-IP-preserving publish; Docker's default for external LAN via iptables
|
||||
DNAT). Two FIFA processes on one machine cannot be distinguished by IP — accepted,
|
||||
since the backend is single-account. The single-client production case is unaffected
|
||||
by NAT because launcher and FIFA share one IP.
|
||||
|
||||
## 11. Fail-closed matrix (Task 15) — every failure ⇒ sentinel
|
||||
|
||||
autopatch missing / not run · guard `UNSUPPORTED_BUILD` / `WRITE_FAILED` /
|
||||
`VERIFY_FAILED` · launcher cannot parse the line · registration POST fails ·
|
||||
account-sync never called · unknown capability version · capability arrives after
|
||||
freeze · capability disappears after a sentinel freeze — **all resolve to the active
|
||||
65534 sentinel.** Asserted by tests (matrix A–J) and this document.
|
||||
|
||||
## 12. P2 retained (Task 16)
|
||||
|
||||
The active-sentinel implementation is **not** removed. It is the else-branch of the
|
||||
switch and the universal default for unpatched clients, unsupported builds, failed
|
||||
patches, unknown launchers, and late capabilities. The clean path is purely additive.
|
||||
|
||||
---
|
||||
|
||||
## 13. Session binding (hardening — supersedes the per-IP prototype)
|
||||
|
||||
**History.** The first implementation keyed the backend capability/store-mode by
|
||||
**source IP alone** (§7 as originally written). That was rejected before deployment:
|
||||
two FIFA processes that share a source IP — concurrent, or a relaunch — would share
|
||||
the key, so an *unverified* process could inherit a *verified* one's `clean-v1`
|
||||
topology and crash on the empty-My-Packs resolver. Source IP is now **auxiliary only**
|
||||
(logging, a fail-closed sid/ip sanity check, and the pending hand-off key). This
|
||||
history is retained deliberately; do not treat per-IP as the design.
|
||||
|
||||
**Authoritative key = the per-login UTAS session id (`X-UT-SID`).** `/ut/auth` now
|
||||
mints a fresh unique SID per login (was a shared constant `OPENFUT-SID-…0001`); the
|
||||
client echoes it on every later call, and it is **live-confirmed present on real
|
||||
`/store/purchasegroup` requests**. The SID uniquely identifies one FIFA process/login:
|
||||
a relaunch re-auths → new SID; two concurrent logins → two SIDs. The legacy constant
|
||||
is still accepted by the retired security-question gate only, and is **never** used to
|
||||
grant `clean-v1`. A store request whose SID was opened on a different source IP is
|
||||
fail-closed to sentinel (sid/ip sanity check).
|
||||
|
||||
**Why not persona alone:** the backend is single-account, so `personaId` cannot
|
||||
distinguish two sessions, and a relaunch keeps the same persona — persona alone would
|
||||
leak a prior session's mode. Persona is used only (with IP) to key the pending hand-off.
|
||||
|
||||
### State machine (per session, keyed by SID)
|
||||
```
|
||||
Capability : Unknown | ResolverV1
|
||||
StoreMode : Unfrozen | Sentinel | CleanV1
|
||||
|
||||
/ut/auth (new SID) : Capability=Unknown, StoreMode=Unfrozen, record {ip,persona}
|
||||
+ consume any pending (ip,persona) -> Capability=ResolverV1
|
||||
capability registered : bind to the one live Unfrozen/Unbound session for (ip,persona)
|
||||
-> Capability=ResolverV1 ; else stage single-use pending ;
|
||||
else (a session exists but is frozen/ambiguous) -> ignored-late
|
||||
first /store/purchasegroup : Unfrozen + ResolverV1 -> freeze CleanV1
|
||||
Unfrozen + otherwise -> freeze Sentinel (consume pending first)
|
||||
late capability : StoreMode already frozen -> unchanged (ignored-late, not staged)
|
||||
capability lost/cleared : after a CleanV1 freeze -> stays CleanV1 (mode is cached)
|
||||
session idle > TTL / reaped: session discarded (a later store with that SID -> Sentinel)
|
||||
```
|
||||
|
||||
### Registration order + pending hand-off
|
||||
The verified capability is known only after the FIFA process exists, CardsDLL is
|
||||
loaded, and autopatch confirms the JG bytes — which may land before or after
|
||||
`/ut/auth`, but reliably before the user opens the Store. The launcher cannot know
|
||||
the SID, so its registration is matched to a session by (source_ip, persona) as a
|
||||
**single-use, short-TTL pending** (`FIFA17_PENDING_TTL = 120s`) that is consumed by
|
||||
exactly one session, at whichever of these happens first for that session: its
|
||||
`/ut/auth` (pending predates login), the registration itself (session already live —
|
||||
bound directly), or its first store request (lazy). If the Store is reached before a
|
||||
capability binds, the session freezes **Sentinel** (fail-closed); a later capability
|
||||
does not change it.
|
||||
|
||||
### Session cleanup (Task 10)
|
||||
- **creation:** at `/ut/auth`.
|
||||
- **last activity:** bumped on every `/store/purchasegroup` for the session.
|
||||
- **freeze:** first `/store/purchasegroup`.
|
||||
- **expiry:** lazy sweep on every session op removes sessions idle for
|
||||
`FIFA17_SESSION_TTL = 3600s` and pendings older than `FIFA17_PENDING_TTL`. Explicit
|
||||
Blaze/UTAS teardown is not reliably observable at this handler, so a conservative
|
||||
activity-based TTL is used instead. Reaping only removes *expired* entries and never
|
||||
affects another live session from the same IP/persona (keyed by distinct SIDs).
|
||||
|
||||
### Residual limitation (documented, fail-closed)
|
||||
FIFA carries no launcher-controllable per-process token, so two **simultaneous** logins
|
||||
from the **same (ip, persona)** cannot be disambiguated at the instant a capability is
|
||||
registered while *both* are Unfrozen/Unbound. That ambiguous case resolves to
|
||||
`ignored-late` → **both freeze Sentinel** (safe: an unverified process is never granted
|
||||
clean). The normal one-launcher-per-FIFA and sequential-relaunch flows bind correctly
|
||||
(proven by matrix K/L/M). This is a UX conservativeness, never a safety hole.
|
||||
|
||||
---
|
||||
|
||||
## 14. Deployment candidate & controlled A/B (overnight reconciliation 2026-08-13)
|
||||
|
||||
**Launcher lineage reconciliation.** The two divergent launcher histories (merge
|
||||
base `87241ac`) were reconciled by a real merge — **not** a rebase/squash/rewrite —
|
||||
in a clean worktree:
|
||||
- `feat/launcher-arming` `13339c1` (client arming + FIFA-17 capability reporting)
|
||||
- `feat/sbc-hook-tracing` `958ff24` (openfut-hook SBC request tracing / RE probes)
|
||||
|
||||
Merged commit **`ca7ce26`** on branch `integration/fifa17-launcher-capability-sbc`
|
||||
retains **both** ancestors (`git merge-base --is-ancestor` true for both `958ff24`
|
||||
and `13339c1`). The only conflict was `src/process.rs` (launcher-arming deleted it +
|
||||
dropped `mod process`; SBC only incidentally tidied it) — resolved **keep-deleted**
|
||||
(orphan module; the SBC feature lives entirely in `openfut-hook/*`). The two features
|
||||
are in disjoint crates/processes (launcher-crate Rust host vs `openfut-hook` Windows
|
||||
DLL) and share no stdout readers, child handles, or lifecycle — no integration code
|
||||
was needed.
|
||||
|
||||
**Gitlink status — DEFERRED (morning blocker).** The superproject gitlink still
|
||||
records the pre-reconciliation `958ff24`. It was **not** bumped to `ca7ce26` because
|
||||
the live submodule checkout carries uncommitted `openfut-hook/*` WIP that overlaps the
|
||||
merged hook content; a non-destructive `git checkout ca7ce26` is refused ("local
|
||||
changes would be overwritten"), and no `-f`/`reset`/`clean` is permitted. The user
|
||||
must first reconcile that WIP against the merged `openfut-hook`, then the gitlink can
|
||||
bump. Preservation artifact: `/tmp/openfut-launcher-overnight-tracked.patch`
|
||||
(sha256 `8e65de2c…`).
|
||||
|
||||
**Validated deployment-candidate tuple** (reproducible from git except the deferred
|
||||
gitlink):
|
||||
```
|
||||
superproject HEAD a82407c (backend per-session + docs)
|
||||
backend guard b0d5e04 fix(fifa17): guard missing store category resolution
|
||||
client proof fc29c2e docs(fifa17): record no-sentinel client resolver proof
|
||||
autopatch report 1c396dd feat(fifa17): report verified client patch capability
|
||||
backend negotiate b25761e feat(fifa17): negotiate clean empty My Packs mode
|
||||
session binding 805d754 fix(fifa17): isolate patched-client capability per session
|
||||
launcher merged HEAD ca7ce26 merge: reconcile launcher capability and SBC tracing
|
||||
(ancestors 13339c1 capability + 958ff24 SBC)
|
||||
launcher gitlink (super) 958ff24 <-- to become ca7ce26 once WIP reconciled
|
||||
```
|
||||
Local build artifacts (NOT deployed): launcher `target/release/openfut-launcher`
|
||||
(sha256 `a390c61d…`); backend image `openfut-fut-backend:candidate-overnight`
|
||||
(`84d280be…`, ships `utas_server.py` `33e0ef3…`). Live `:dev` image and the running
|
||||
container were left untouched.
|
||||
|
||||
### Controlled A/B sequence (execute only in a later authorized deploy task)
|
||||
**A — patched client:** fresh FIFA process → autopatch verifies the JG guard →
|
||||
launcher parses the verified line and registers → `/ut/auth` mints a fresh `X-UT-SID`
|
||||
→ capability binds to that SID → first `/store/purchasegroup` freezes `clean-v1` →
|
||||
backend omits 65534 → Store opens on Browse Packs, no crash.
|
||||
**B — unpatched client, same machine/IP, NEW session:** new `X-UT-SID`, no verified
|
||||
capability → first store freezes `sentinel` → backend emits active 65534 → no crash.
|
||||
Proves same-IP isolation + fail-closed fallback.
|
||||
**C — failed patch (optional):** autopatch reports `UNSUPPORTED_BUILD`/`VERIFY_FAILED`
|
||||
→ launcher never registers → `sentinel`.
|
||||
Production remains the P2 active-sentinel universal default until this A/B passes.
|
||||
+1
-1
Submodule fifa-blaze updated: d2a9a01ec9...f4f33969f2
@@ -3,7 +3,7 @@
|
||||
# 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_ADVERTISE=203.0.113.10 # <- REPLACE with this host's LAN IP
|
||||
|
||||
# OPENFUT_BIND — address the listeners bind inside the container.
|
||||
# Defaults to 0.0.0.0 (container-facing); the original all-on-localhost flow
|
||||
|
||||
@@ -20,13 +20,15 @@
|
||||
# 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=10.10.0.120 ./client_arm.sh
|
||||
# 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. 10.10.0.120}"
|
||||
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
|
||||
@@ -55,13 +57,46 @@ 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:]]easw\.easports\.com\b.*# openfut$/d' /etc/hosts
|
||||
printf '%s\teasw.easports.com\t# openfut\n' "$SERVER" >> /etc/hosts
|
||||
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!)"
|
||||
grep 'easw.easports.com' /etc/hosts && echo " /etc/hosts ok" || echo " (/etc/hosts easw 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."
|
||||
|
||||
@@ -24,7 +24,7 @@ services:
|
||||
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}"
|
||||
OPENFUT_ADVERTISE: "${OPENFUT_ADVERTISE:?set OPENFUT_ADVERTISE in .env to this host's LAN IP, e.g. 203.0.113.10}"
|
||||
# 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.
|
||||
|
||||
@@ -11,13 +11,13 @@
|
||||
# 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)
|
||||
# (the server's LAN IP, e.g. 203.0.113.10)
|
||||
# ============================================================================
|
||||
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)}"
|
||||
ADV="${OPENFUT_ADVERTISE:?OPENFUT_ADVERTISE must be set to the server LAN IP (e.g. 203.0.113.10)}"
|
||||
export OPENFUT_BIND="$BIND"
|
||||
export OPENFUT_ADVERTISE="$ADV"
|
||||
# POW keys advertised by blaze must also point at the server, not loopback.
|
||||
|
||||
@@ -24,6 +24,45 @@ STORE_PATCHES = {
|
||||
0x1800175aa: NOP2,
|
||||
}
|
||||
|
||||
# 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):
|
||||
@@ -52,9 +91,45 @@ def wr(pid,va,b):
|
||||
with open(f'/proc/{pid}/mem','r+b') as f:
|
||||
f.seek(va); f.write(b)
|
||||
|
||||
def guarded_action(cur, orig, patch):
|
||||
"""Fail-closed decision for a guarded byte patch (see STORE_PATCHES_GUARDED).
|
||||
|
||||
Returns "noop" when the live bytes are already patched, "patch" when they are the
|
||||
known original (safe to apply), or "skip" for anything else -- an unrecognised
|
||||
CardsDLL build that must never be blindly overwritten.
|
||||
"""
|
||||
if cur == patch:
|
||||
return "noop"
|
||||
if cur == orig:
|
||||
return "patch"
|
||||
return "skip"
|
||||
|
||||
def guard_state_after(cur_before, orig, patch, wrote_ok, cur_after):
|
||||
"""Map a guarded-patch enforcement outcome to a per-pid guard STATE (pure).
|
||||
|
||||
Mirrors guarded_action's decision, extended with post-write verification so the
|
||||
caller advertises the capability only on VERIFIED. No /proc access -- unit-testable.
|
||||
|
||||
- cur_before == patch -> VERIFIED (already patched; guarded_action "noop")
|
||||
- cur_before == orig -> WRITE_FAILED if the write raised, else VERIFIED when the
|
||||
re-read is patch, else VERIFY_FAILED (guarded_action "patch")
|
||||
- otherwise -> UNSUPPORTED_BUILD (guarded_action "skip")
|
||||
"""
|
||||
if cur_before == patch:
|
||||
return GUARD_VERIFIED
|
||||
if cur_before == orig:
|
||||
if not wrote_ok:
|
||||
return GUARD_WRITE_FAILED
|
||||
if cur_after == patch:
|
||||
return GUARD_VERIFIED
|
||||
return GUARD_VERIFY_FAILED
|
||||
return GUARD_UNSUPPORTED_BUILD
|
||||
|
||||
patched=set()
|
||||
store_patched=set()
|
||||
guard_reported=set()
|
||||
|
||||
if __name__ == "__main__":
|
||||
launcher_pid = None
|
||||
if "--launcher-pid" in sys.argv:
|
||||
try: launcher_pid = int(sys.argv[sys.argv.index("--launcher-pid") + 1])
|
||||
@@ -90,6 +165,34 @@ while True:
|
||||
if rd(pid, live, len(data)) != data:
|
||||
wr(pid, live, data)
|
||||
log(f"pid {pid}: ENFORCED store patch @ {live:#x}")
|
||||
for va, (orig, patch) in STORE_PATCHES_GUARDED.items():
|
||||
live = cbase + (va - IMG_BASE)
|
||||
cur = rd(pid, live, len(patch))
|
||||
action = guarded_action(cur, orig, patch)
|
||||
wrote_ok = True
|
||||
cur_after = cur
|
||||
if action == "patch":
|
||||
try:
|
||||
wr(pid, live, patch)
|
||||
log(f"pid {pid}: ENFORCED guarded store patch @ {live:#x} (JNZ->JG, empty My Packs)")
|
||||
except Exception as e:
|
||||
wrote_ok = False
|
||||
log(f"pid {pid}: guarded patch write failed @ {live:#x}: {e}")
|
||||
if wrote_ok:
|
||||
try:
|
||||
cur_after = rd(pid, live, len(patch))
|
||||
except Exception:
|
||||
cur_after = b""
|
||||
elif action == "skip":
|
||||
log(f"pid {pid}: SKIP guarded patch @ {live:#x}: unexpected {cur.hex()} (build mismatch)")
|
||||
# action == "noop": already patched; nothing to write.
|
||||
if va == RESOLVER_GUARD_VA and pid not in guard_reported:
|
||||
state = guard_state_after(cur, orig, patch, wrote_ok, cur_after)
|
||||
if state == GUARD_VERIFIED:
|
||||
log(f"[store-guard] verified capability {EMPTY_MYPACKS_RESOLVER_CAPABILITY}={EMPTY_MYPACKS_RESOLVER_VERSION} fifa_pid={pid}")
|
||||
else:
|
||||
log(f"[store-guard] guard status={state} fifa_pid={pid} (no capability advertised)")
|
||||
guard_reported.add(pid)
|
||||
if pid not in store_patched:
|
||||
log(f"pid {pid}: PATCHED store gates in CardsDLL @ {cbase:#x}")
|
||||
store_patched.add(pid)
|
||||
|
||||
@@ -15,16 +15,27 @@ reimplementations (the `tdf` crate cloned in this scratchpad), which were used
|
||||
only as a cross-check of *structure*, never copied.
|
||||
NO EA/FIFA leaked source was consulted.
|
||||
|
||||
VALIDATED RULES (byte-exact round-trip against the 219-byte capture)
|
||||
--------------------------------------------------------------------
|
||||
Fire2 frame header, 16 bytes big-endian:
|
||||
[0:4] u32 payload length (bytes after the header)
|
||||
[4:6] u16 always 0 (observed)
|
||||
VALIDATED RULES (the TDF body; byte-exact round-trip against the 219-byte capture)
|
||||
---------------------------------------------------------------------------------
|
||||
Fire2 frame header, 16 bytes big-endian.
|
||||
|
||||
!!! SUPERSEDED — the [10:16] FIELD SEMANTICS below are WRONG for FIFA 17. !!!
|
||||
The "byte-exact round-trip" only proves the payload length and the TDF body
|
||||
encoding: decoding then re-encoding with the SAME (mis)labelled header layout
|
||||
trivially reproduces the capture, so it never tested the header's field
|
||||
boundaries. The authoritative, live-driven layout is
|
||||
`openfut-protocol-blaze::fire2` / `blaze_responder_v3b.py::fire2`:
|
||||
[0:4] u32 payload length (bytes after header + metadata)
|
||||
[4:6] u16 metadata length (0 when absent — what this file called "always 0")
|
||||
[6:8] u16 component
|
||||
[8:10] u16 command
|
||||
[10:12]u16 error / msgId
|
||||
[12] u8 msgType (0x01 ping, 0x02 request, 0x03 pong/response)
|
||||
[13:16]3 reserved bytes (observed 00 00 00)
|
||||
[10:13] u24 msgNum (this file WRONGLY split it as [10:12] msgId + [12] msgType)
|
||||
[13] u8 (msgType << 5) | (userIndex & 0x1F)
|
||||
[14] u8 options
|
||||
[15] u8 reserved
|
||||
There is NO error field in Fire2 (that is Fire v1) and NO jumbo escape — the
|
||||
length is already a full u32. `build_fire2_frame`/`decode_fire2` below keep the
|
||||
old wrong `>IHHHHB3s` layout; they are dead and retained only for history.
|
||||
|
||||
Heat2 field = 3-byte packed tag + 1 type byte + value.
|
||||
|
||||
@@ -359,7 +370,14 @@ MSG_ERROR = 0x05 # UNVERIFIED
|
||||
|
||||
def build_fire2_frame(component: int, command: int, msgType: int,
|
||||
msgId: int, tdf_bytes: bytes) -> bytes:
|
||||
"""16-byte big-endian Fire2 header + TDF payload."""
|
||||
"""16-byte big-endian Fire2 header + TDF payload.
|
||||
|
||||
WRONG HEADER (dead code): the ``>IHHHHB3s`` layout mislabels [10:16] — it
|
||||
puts a u16 msgId at [10:12] and msgType at [12]. FIFA 17's real Fire2 header
|
||||
is [10:13] u24 msgNum, [13] (msgType<<5)|userIndex, [14] options, [15]
|
||||
reserved, and has no error field. Use ``openfut-protocol-blaze::fire2`` or
|
||||
``blaze_responder_v3b.py::fire2``; this is retained only for history.
|
||||
"""
|
||||
tdf_bytes = bytes(tdf_bytes)
|
||||
hdr = struct.pack(">IHHHHB3s", len(tdf_bytes), 0, component & 0xFFFF,
|
||||
command & 0xFFFF, msgId & 0xFFFF, msgType & 0xFF,
|
||||
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Pure unit test for the empty-My-Packs store resolver guard in autopatch.py.
|
||||
|
||||
Covers the fail-closed guard decision (original -> PATCH, already-patched -> NOOP,
|
||||
unknown -> SKIP) and pins the guarded patch table to the exact RVA/bytes proven on
|
||||
the tested FIFA 17 build (JNZ 0x14869 -> JG 0x14869 at CardsDLL RVA 0x14858).
|
||||
|
||||
Run: python3 test_autopatch_guard.py
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import autopatch # importable: runtime loop is guarded by `if __name__ == "__main__"`
|
||||
|
||||
GUARD_VA = 0x180014858
|
||||
ORIG = bytes.fromhex("750f") # JNZ 0x14869
|
||||
PATCH = bytes.fromhex("7f0f") # JG 0x14869
|
||||
|
||||
|
||||
def test_table_exact():
|
||||
assert autopatch.STORE_PATCHES_GUARDED == {GUARD_VA: (ORIG, PATCH)}, \
|
||||
autopatch.STORE_PATCHES_GUARDED
|
||||
# Byte-level pin so a bad hex literal cannot slip through.
|
||||
assert ORIG == b"\x75\x0f" and PATCH == b"\x7f\x0f"
|
||||
|
||||
|
||||
def test_decision():
|
||||
assert autopatch.guarded_action(ORIG, ORIG, PATCH) == "patch" # apply
|
||||
assert autopatch.guarded_action(PATCH, ORIG, PATCH) == "noop" # already patched
|
||||
assert autopatch.guarded_action(b"\x00\x00", ORIG, PATCH) == "skip" # build mismatch
|
||||
assert autopatch.guarded_action(b"\x90", ORIG, PATCH) == "skip" # wrong length
|
||||
|
||||
|
||||
def test_guard_state_after():
|
||||
# already patched (7f0f) -> VERIFIED (guarded_action "noop"); write args irrelevant.
|
||||
assert autopatch.guard_state_after(PATCH, ORIG, PATCH, True, PATCH) == autopatch.GUARD_VERIFIED
|
||||
# original (750f) + write ok + reread 7f0f -> VERIFIED (guarded_action "patch").
|
||||
assert autopatch.guard_state_after(ORIG, ORIG, PATCH, True, PATCH) == autopatch.GUARD_VERIFIED
|
||||
# original + write FAILS -> WRITE_FAILED.
|
||||
assert autopatch.guard_state_after(ORIG, ORIG, PATCH, False, ORIG) == autopatch.GUARD_WRITE_FAILED
|
||||
# original + write ok but reread != 7f0f -> VERIFY_FAILED.
|
||||
assert autopatch.guard_state_after(ORIG, ORIG, PATCH, True, ORIG) == autopatch.GUARD_VERIFY_FAILED
|
||||
assert autopatch.guard_state_after(ORIG, ORIG, PATCH, True, b"") == autopatch.GUARD_VERIFY_FAILED
|
||||
# unknown bytes -> UNSUPPORTED_BUILD (guarded_action "skip"); write args irrelevant.
|
||||
assert autopatch.guard_state_after(b"\x00\x00", ORIG, PATCH, True, PATCH) == autopatch.GUARD_UNSUPPORTED_BUILD
|
||||
|
||||
|
||||
def test_capability_constants():
|
||||
assert autopatch.EMPTY_MYPACKS_RESOLVER_VERSION == 1
|
||||
assert autopatch.EMPTY_MYPACKS_RESOLVER_CAPABILITY == "fifa17.empty_mypacks_resolver"
|
||||
# State constant values are the exact tokens carried in the emitted status line.
|
||||
assert autopatch.GUARD_VERIFIED == "VERIFIED"
|
||||
assert autopatch.GUARD_UNSUPPORTED_BUILD == "UNSUPPORTED_BUILD"
|
||||
assert autopatch.GUARD_WRITE_FAILED == "WRITE_FAILED"
|
||||
assert autopatch.GUARD_VERIFY_FAILED == "VERIFY_FAILED"
|
||||
assert autopatch.GUARD_NOT_ATTEMPTED == "NOT_ATTEMPTED"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_table_exact()
|
||||
test_decision()
|
||||
test_guard_state_after()
|
||||
test_capability_constants()
|
||||
print("OK: autopatch guard table + fail-closed decision + guard-state function + capability constants")
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the FIFA 17 verified-patched-client capability negotiation.
|
||||
|
||||
The additive empty-My-Packs switch on top of the P2 65534 sentinel: the sentinel is
|
||||
suppressed for ONE FIFA session only when the launcher has registered a verified
|
||||
resolver capability (v1) that binds to THAT process's UTAS session (keyed by the
|
||||
per-login-unique X-UT-SID; source IP + persona are auxiliary). Every failure /
|
||||
unknown / late / cross-process / cross-session case is fail-closed to the sentinel.
|
||||
|
||||
The initial prototype keyed by source IP alone; this suite proves the hardened
|
||||
per-session binding, including two sessions that SHARE a source IP.
|
||||
|
||||
Matrix (docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md):
|
||||
A no-capability, zero packs -> sentinel
|
||||
B verified v1, zero packs -> clean (no 65534)
|
||||
C real unopened pack + no capability -> genuine pack, no sentinel
|
||||
D real unopened pack + capability -> genuine pack, no sentinel
|
||||
E unsupported version / capability -> endpoint 400 AND mode sentinel
|
||||
F late capability after sentinel freeze -> stays sentinel
|
||||
G capability disappears after clean freeze -> stays clean (immutable)
|
||||
H two IPs (A verified, B none) -> A clean, B sentinel (no global leak)
|
||||
I new session after reset -> fresh unpatched -> sentinel
|
||||
J autopatch mismatch => never registers -> sentinel
|
||||
K SAME IP, two sessions (A patched, B not) -> A clean, B sentinel
|
||||
L SAME IP+persona relaunch (old ok, new not) -> new session sentinel
|
||||
M SAME IP, failed-patch second session -> first clean, second sentinel
|
||||
N late registration when sessions are frozen -> does not modify active sessions
|
||||
O session cleanup / TTL expiry -> capability gone, sentinel
|
||||
P duplicate registration for a session -> idempotent; no post-freeze change
|
||||
Q register-before-login (pending consumed) -> clean
|
||||
R topology freeze immutable per SID -> no flip either way; new SID fresh
|
||||
|
||||
Standalone unit test in the project style: `python3 test_capability_negotiation.py`.
|
||||
"""
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
TOOLS = os.path.dirname(os.path.abspath(__file__))
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
SENTINEL_ID = 65534
|
||||
REAL_PACK_ID = 1
|
||||
PERSONA = 111001
|
||||
|
||||
|
||||
class _H:
|
||||
"""Minimal request-handler stand-in: peer IP, optional X-UT-SID, optional body."""
|
||||
|
||||
def __init__(self, ip, body=None, sid=None):
|
||||
self.client_address = (ip, 54321)
|
||||
self.headers = {"X-UT-SID": sid} if sid is not None else {}
|
||||
self._body = json.dumps(body).encode("utf-8") if body is not None else b""
|
||||
|
||||
|
||||
def _ids(catalog):
|
||||
return [p["id"] for p in catalog["purchase"]]
|
||||
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory() as state:
|
||||
os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json")
|
||||
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
|
||||
os.environ.pop("FUT_PROFILE", None)
|
||||
|
||||
import fut_account
|
||||
import fut_store
|
||||
import fut_accounts
|
||||
import utas_server
|
||||
importlib.reload(fut_account)
|
||||
importlib.reload(fut_store)
|
||||
importlib.reload(fut_accounts)
|
||||
importlib.reload(utas_server)
|
||||
|
||||
us = utas_server
|
||||
CLEAN, SENT = us.FIFA17_MODE_CLEAN, us.FIFA17_MODE_SENTINEL
|
||||
|
||||
_orig_visible = us.visible_unopened_packs
|
||||
|
||||
def set_zero_packs():
|
||||
us.visible_unopened_packs = lambda: []
|
||||
|
||||
def set_real_pack():
|
||||
us.visible_unopened_packs = lambda: [REAL_PACK_ID]
|
||||
|
||||
def reset_state():
|
||||
us._FIFA17_SESSIONS.clear()
|
||||
us._FIFA17_PENDING.clear()
|
||||
|
||||
def auth(sid, ip, persona=PERSONA):
|
||||
"""Simulate /ut/auth opening a per-login session with a chosen sid."""
|
||||
us.fifa17_open_session(sid, ip, persona)
|
||||
|
||||
def register(ip, version, persona=PERSONA, pid=4242):
|
||||
return us.fifa17_capability_route(_H(ip, {
|
||||
"capability": "empty_mypacks_resolver", "version": version,
|
||||
"personaId": persona, "fifaPid": pid,
|
||||
}))
|
||||
|
||||
def store(sid, ip):
|
||||
status, cat = us.store_catalog(_H(ip, sid=sid))
|
||||
assert status == 200, status
|
||||
return _ids(cat)
|
||||
|
||||
def mode_of(sid):
|
||||
return us._FIFA17_SESSIONS[sid]["mode"]
|
||||
|
||||
try:
|
||||
# ---- A. no capability, zero packs -> sentinel ----------------------
|
||||
reset_state(); set_zero_packs()
|
||||
auth("sidA", "10.0.0.1")
|
||||
assert SENTINEL_ID in store("sidA", "10.0.0.1")
|
||||
assert mode_of("sidA") == SENT
|
||||
print("A no-capability zero-packs -> sentinel: OK")
|
||||
|
||||
# ---- B. verified v1, zero packs -> clean ---------------------------
|
||||
reset_state(); set_zero_packs()
|
||||
auth("sidB", "10.0.0.2")
|
||||
assert register("10.0.0.2", 1)[0] == 200
|
||||
ids = store("sidB", "10.0.0.2")
|
||||
assert SENTINEL_ID not in ids, ids
|
||||
assert mode_of("sidB") == CLEAN
|
||||
print("B verified-v1 zero-packs -> clean: OK")
|
||||
|
||||
# ---- C. real pack + no capability -> genuine, no sentinel ----------
|
||||
reset_state(); set_real_pack()
|
||||
auth("sidC", "10.0.0.3")
|
||||
ids = store("sidC", "10.0.0.3")
|
||||
assert REAL_PACK_ID in ids and SENTINEL_ID not in ids, ids
|
||||
print("C real-pack no-capability -> genuine, no sentinel: OK")
|
||||
|
||||
# ---- D. real pack + capability -> genuine, no sentinel -------------
|
||||
reset_state(); set_real_pack()
|
||||
auth("sidD", "10.0.0.4"); register("10.0.0.4", 1)
|
||||
ids = store("sidD", "10.0.0.4")
|
||||
assert REAL_PACK_ID in ids and SENTINEL_ID not in ids, ids
|
||||
print("D real-pack capability -> genuine, no sentinel: OK")
|
||||
|
||||
# ---- E. unsupported version / capability -> 400 + sentinel ---------
|
||||
reset_state(); set_zero_packs()
|
||||
auth("sidE", "10.0.0.5")
|
||||
assert register("10.0.0.5", 2)[0] == 400
|
||||
assert register("10.0.0.5", 99)[0] == 400
|
||||
assert us.fifa17_capability_route(
|
||||
_H("10.0.0.5", {"capability": "bogus", "version": 1}))[0] == 400
|
||||
assert SENTINEL_ID in store("sidE", "10.0.0.5")
|
||||
assert mode_of("sidE") == SENT
|
||||
print("E unsupported version/capability -> 400 + sentinel: OK")
|
||||
|
||||
# ---- F. late capability after sentinel freeze -> sentinel ----------
|
||||
reset_state(); set_zero_packs()
|
||||
auth("sidF", "10.0.0.6")
|
||||
assert SENTINEL_ID in store("sidF", "10.0.0.6") # freezes sentinel
|
||||
assert register("10.0.0.6", 1)[0] == 200 # session frozen -> ignored-late
|
||||
assert SENTINEL_ID in store("sidF", "10.0.0.6")
|
||||
assert mode_of("sidF") == SENT
|
||||
print("F late capability after sentinel freeze -> sentinel: OK")
|
||||
|
||||
# ---- G. capability disappears after clean freeze -> clean ----------
|
||||
reset_state(); set_zero_packs()
|
||||
auth("sidG", "10.0.0.7"); register("10.0.0.7", 1)
|
||||
assert SENTINEL_ID not in store("sidG", "10.0.0.7") # freezes clean
|
||||
us._FIFA17_SESSIONS["sidG"]["resolver"] = None # capability vanishes
|
||||
assert SENTINEL_ID not in store("sidG", "10.0.0.7")
|
||||
assert mode_of("sidG") == CLEAN
|
||||
print("G capability disappears after clean freeze -> clean: OK")
|
||||
|
||||
# ---- H. two IPs (A verified, B none) -> no global leak -------------
|
||||
reset_state(); set_zero_packs()
|
||||
auth("sidH1", "10.0.1.1"); register("10.0.1.1", 1)
|
||||
auth("sidH2", "10.0.1.2")
|
||||
assert SENTINEL_ID not in store("sidH1", "10.0.1.1")
|
||||
assert SENTINEL_ID in store("sidH2", "10.0.1.2")
|
||||
print("H two IPs (A clean, B sentinel) -> no global leak: OK")
|
||||
|
||||
# ---- I. new session after reset -> fresh unpatched -> sentinel -----
|
||||
reset_state(); set_zero_packs()
|
||||
auth("sidI1", "10.0.1.3"); register("10.0.1.3", 1)
|
||||
assert SENTINEL_ID not in store("sidI1", "10.0.1.3") # A clean
|
||||
us.fifa17_clear_pending("10.0.1.3") # relaunch boundary
|
||||
auth("sidI2", "10.0.1.3") # new SID, autopatch failed
|
||||
assert SENTINEL_ID in store("sidI2", "10.0.1.3")
|
||||
print("I new session after reset -> sentinel (no cross-process leak): OK")
|
||||
|
||||
# ---- J. autopatch mismatch => never registers -> sentinel ----------
|
||||
reset_state(); set_zero_packs()
|
||||
auth("sidJ", "10.0.1.4")
|
||||
assert SENTINEL_ID in store("sidJ", "10.0.1.4")
|
||||
print("J autopatch mismatch (never registers) -> sentinel: OK")
|
||||
|
||||
# ---- K. SAME IP, two sessions: patched A clean, unpatched B sent ---
|
||||
reset_state(); set_zero_packs()
|
||||
IP = "10.0.2.1"
|
||||
auth("sidK_A", IP)
|
||||
assert register(IP, 1)[0] == 200 # A sole candidate -> bound
|
||||
auth("sidK_B", IP) # B joins, never registers
|
||||
assert SENTINEL_ID not in store("sidK_A", IP)
|
||||
assert SENTINEL_ID in store("sidK_B", IP)
|
||||
print("K same-IP two sessions -> A clean, B sentinel: OK")
|
||||
|
||||
# ---- L. SAME IP+persona relaunch: old ok, new not -> new sentinel --
|
||||
reset_state(); set_zero_packs()
|
||||
IP = "10.0.2.2"
|
||||
auth("sidL_old", IP, PERSONA); register(IP, 1, PERSONA)
|
||||
assert SENTINEL_ID not in store("sidL_old", IP)
|
||||
us.fifa17_clear_pending(IP)
|
||||
auth("sidL_new", IP, PERSONA) # same persona, unverified
|
||||
assert SENTINEL_ID in store("sidL_new", IP)
|
||||
print("L same-IP+persona relaunch -> new session sentinel: OK")
|
||||
|
||||
# ---- M. SAME IP, failed-patch second session -----------------------
|
||||
reset_state(); set_zero_packs()
|
||||
IP = "10.0.2.3"
|
||||
auth("sidM1", IP); register(IP, 1)
|
||||
assert SENTINEL_ID not in store("sidM1", IP)
|
||||
auth("sidM2", IP) # autopatch failed
|
||||
assert SENTINEL_ID in store("sidM2", IP)
|
||||
print("M same-IP failed-patch second session -> sentinel: OK")
|
||||
|
||||
# ---- N. late reg when sessions frozen -> no active session change --
|
||||
reset_state(); set_zero_packs()
|
||||
IP = "10.0.2.4"
|
||||
auth("sidN1", IP); register(IP, 1)
|
||||
assert SENTINEL_ID not in store("sidN1", IP) # N1 frozen clean
|
||||
auth("sidN2", IP)
|
||||
assert SENTINEL_ID in store("sidN2", IP) # N2 frozen sentinel
|
||||
assert register(IP, 1)[0] == 200 # late: both frozen -> ignored
|
||||
assert SENTINEL_ID not in store("sidN1", IP) # unchanged
|
||||
assert SENTINEL_ID in store("sidN2", IP) # unchanged
|
||||
print("N late registration does not modify active sessions: OK")
|
||||
|
||||
# ---- O. session cleanup / TTL expiry -> capability gone ------------
|
||||
reset_state(); set_zero_packs()
|
||||
IP = "10.0.2.5"
|
||||
auth("sidO", IP); register(IP, 1)
|
||||
assert SENTINEL_ID not in store("sidO", IP) # clean while live
|
||||
us._FIFA17_SESSIONS["sidO"]["last_seen"] = (
|
||||
us._fifa17_now() - us.FIFA17_SESSION_TTL - 10.0)
|
||||
store("sidUNKNOWN", IP) # any op triggers reap
|
||||
assert "sidO" not in us._FIFA17_SESSIONS, "expired session not reaped"
|
||||
assert SENTINEL_ID in store("sidO", IP) # gone -> sentinel
|
||||
print("O session cleanup / TTL expiry -> sentinel: OK")
|
||||
|
||||
# ---- P. duplicate registration -> idempotent, no post-freeze change
|
||||
reset_state(); set_zero_packs()
|
||||
IP = "10.0.2.6"
|
||||
auth("sidP", IP)
|
||||
assert register(IP, 1)[0] == 200 # bound
|
||||
assert register(IP, 1)[0] == 200 # duplicate -> ignored-late
|
||||
assert SENTINEL_ID not in store("sidP", IP) # still clean
|
||||
assert register(IP, 1)[0] == 200 # after freeze
|
||||
assert SENTINEL_ID not in store("sidP", IP) # unchanged
|
||||
assert mode_of("sidP") == CLEAN
|
||||
print("P duplicate registration -> idempotent: OK")
|
||||
|
||||
# ---- Q. register-before-login: pending consumed at auth -> clean ---
|
||||
reset_state(); set_zero_packs()
|
||||
IP = "10.0.2.7"
|
||||
assert register(IP, 1)[0] == 200 # no session yet -> pending
|
||||
assert (IP, PERSONA) in us._FIFA17_PENDING
|
||||
auth("sidQ", IP, PERSONA) # consumes pending
|
||||
assert (IP, PERSONA) not in us._FIFA17_PENDING # single-use
|
||||
assert SENTINEL_ID not in store("sidQ", IP)
|
||||
assert mode_of("sidQ") == CLEAN
|
||||
print("Q register-before-login pending consumed -> clean: OK")
|
||||
|
||||
# ---- R. topology freeze immutable per SID; new SID decides fresh ----
|
||||
# F3 invariant: once a SID's store topology is decided it NEVER flips,
|
||||
# in either direction, and a different SID may decide differently.
|
||||
reset_state(); set_zero_packs()
|
||||
IP = "10.0.2.8"
|
||||
# frozen Sentinel never becomes Clean, even if a capability appears later
|
||||
auth("sidR_s", IP)
|
||||
assert SENTINEL_ID in store("sidR_s", IP) # freeze Sentinel
|
||||
register(IP, 1)
|
||||
us._FIFA17_SESSIONS["sidR_s"]["resolver"] = 1 # force-present capability
|
||||
assert SENTINEL_ID in store("sidR_s", IP) # STILL Sentinel
|
||||
assert mode_of("sidR_s") == SENT
|
||||
# frozen Clean never becomes Sentinel, even if the capability is wiped
|
||||
auth("sidR_c", IP); register(IP, 1)
|
||||
assert SENTINEL_ID not in store("sidR_c", IP) # freeze Clean
|
||||
us._FIFA17_SESSIONS["sidR_c"]["resolver"] = None # capability vanishes
|
||||
assert SENTINEL_ID not in store("sidR_c", IP) # STILL Clean
|
||||
assert mode_of("sidR_c") == CLEAN
|
||||
# a fresh SID (same IP) decides independently
|
||||
auth("sidR_new", IP)
|
||||
assert SENTINEL_ID in store("sidR_new", IP)
|
||||
print("R topology freeze immutable per SID; new SID fresh: OK")
|
||||
|
||||
finally:
|
||||
us.visible_unopened_packs = _orig_visible
|
||||
|
||||
print("capability negotiation matrix A-R: OK")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Regression tests for the empty-My-Packs FIFA 17 compatibility workaround (bug 6c).
|
||||
|
||||
Pins the behavior store_catalog() now depends on:
|
||||
- unopenedPackIds == [] -> exactly one synthetic active `mypacks` placeholder id 65534
|
||||
- unopenedPackIds == [70] -> no synthetic placeholder; the genuine owned pack is shown
|
||||
- synthetic id 65534 stays economy-safe (non-resolvable, non-openable, non-granting)
|
||||
- normal store packs (1/5/6/7) are untouched by the empty-state behavior
|
||||
|
||||
See docs/evidence/STORE_TILE_6C.md and FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md.
|
||||
Standalone unit test in the project style: `python3 test_empty_mypacks.py`.
|
||||
"""
|
||||
import importlib
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
TOOLS = os.path.dirname(os.path.abspath(__file__))
|
||||
if TOOLS not in sys.path:
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
SENTINEL_ID = 65534
|
||||
|
||||
|
||||
def _set_unopened(fut_store, ids):
|
||||
"""Deterministically set the active profile's owned unopened packs."""
|
||||
p = fut_store.STORE.load()
|
||||
p["unopenedPackIds"] = list(ids)
|
||||
fut_store.STORE._save()
|
||||
|
||||
|
||||
def _mypacks(catalog):
|
||||
return [p for p in catalog["purchase"]
|
||||
if (p.get("displayGroup") or {}).get("value") == "mypacks"]
|
||||
|
||||
|
||||
def _fake_request(command, body):
|
||||
class _H:
|
||||
pass
|
||||
h = _H()
|
||||
h.command = command
|
||||
h._body = body
|
||||
return h
|
||||
|
||||
|
||||
def main():
|
||||
with tempfile.TemporaryDirectory() as state:
|
||||
os.environ["FUT_ACCOUNT_PATH"] = os.path.join(state, "active_account.json")
|
||||
os.environ["FUT_PROFILE_ROOT"] = os.path.join(state, "accounts")
|
||||
os.environ.pop("FUT_PROFILE", None)
|
||||
|
||||
import fut_account
|
||||
import fut_store
|
||||
import fut_accounts
|
||||
import utas_server
|
||||
importlib.reload(fut_account)
|
||||
importlib.reload(fut_store)
|
||||
importlib.reload(fut_accounts)
|
||||
importlib.reload(utas_server)
|
||||
|
||||
fut_accounts.activate({"personaId": 111001, "personaName": "TEST_A"})
|
||||
catalog_ids = [p["id"] for p in fut_store.PACK_CATALOG]
|
||||
|
||||
# ---- A. Empty unopened packs -> one active synthetic 65534 placeholder ----
|
||||
_set_unopened(fut_store, [])
|
||||
utas_server._OPENED_PACK_GRACE.clear()
|
||||
status, cat = utas_server.store_catalog(None)
|
||||
assert status == 200
|
||||
myp = _mypacks(cat)
|
||||
assert len(myp) == 1, "expected exactly one mypacks entry, got %r" % myp
|
||||
s = myp[0]
|
||||
assert s["id"] == SENTINEL_ID, s
|
||||
assert s["state"] == "active", s # the P2 fix: active, not inactive
|
||||
assert (s.get("displayGroup") or {}).get("value") == "mypacks", s
|
||||
assert SENTINEL_ID not in catalog_ids, "65534 must not be in PACK_CATALOG"
|
||||
assert fut_store.pack_by_id(SENTINEL_ID) is None
|
||||
print("A empty-state active placeholder: PASS")
|
||||
|
||||
# ---- D (empty half). Normal packs untouched in empty state ----
|
||||
norm = {p["id"]: p for p in cat["purchase"] if p["id"] in (1, 5, 6, 7)}
|
||||
assert set(norm) == {1, 5, 6, 7}, sorted(norm)
|
||||
assert all(norm[i]["state"] == "active" for i in norm), norm
|
||||
assert norm[1]["packType"] == "BRONZE" and norm[1]["description"] == "Bronze Pack"
|
||||
|
||||
# ---- B. Non-empty unopened packs -> NO synthetic; genuine owned pack shown ----
|
||||
_set_unopened(fut_store, [70])
|
||||
utas_server._OPENED_PACK_GRACE.clear()
|
||||
status, cat = utas_server.store_catalog(None)
|
||||
assert status == 200
|
||||
ids = [p["id"] for p in cat["purchase"]]
|
||||
assert SENTINEL_ID not in ids, "synthetic placeholder must be suppressed when a pack exists"
|
||||
myp = _mypacks(cat)
|
||||
assert len(myp) == 1 and myp[0]["id"] == 70, myp
|
||||
assert myp[0]["state"] == "active" and myp[0]["unopened"] is True, myp[0]
|
||||
# normal packs still intact alongside the owned pack
|
||||
assert {1, 5, 6, 7}.issubset(set(ids)), sorted(ids)
|
||||
print("B non-empty-state genuine pack: PASS")
|
||||
|
||||
# ---- C. Economy safety of the synthetic placeholder ----
|
||||
_set_unopened(fut_store, [])
|
||||
utas_server._OPENED_PACK_GRACE.clear()
|
||||
coins0 = fut_store.STORE.coins()
|
||||
items0 = len(fut_store.STORE.items())
|
||||
next0 = fut_store.STORE.load()["nextItemId"]
|
||||
|
||||
assert fut_store.pack_by_id(SENTINEL_ID) is None
|
||||
|
||||
# store_buy: a confirmed-buy transaction for 65534 must be a no-op {}
|
||||
status, body = utas_server.store_buy(
|
||||
_fake_request("PUT", b'{"packId":65534,"state":"TRANSACTIONCREATED"}'))
|
||||
assert status == 200 and body == {}, (status, body)
|
||||
|
||||
# purchased_items: POST buy for 65534 must not open/grant anything
|
||||
status, body = utas_server.purchased_items(
|
||||
_fake_request("POST", b'{"packId":65534,"useCredits":1,"usePreOrder":0,"currency":"COINS"}'))
|
||||
assert status == 200, (status, body)
|
||||
assert "createPackResponse" not in body, body
|
||||
|
||||
# 65534 cannot enter the owned-pack pile (not a catalog pack)
|
||||
assert fut_store.STORE.grant_unopened_pack(SENTINEL_ID) is False
|
||||
assert SENTINEL_ID not in fut_store.STORE.unopened_packs()
|
||||
|
||||
# nothing mutated
|
||||
assert fut_store.STORE.coins() == coins0, (fut_store.STORE.coins(), coins0)
|
||||
assert len(fut_store.STORE.items()) == items0
|
||||
assert fut_store.STORE.load()["nextItemId"] == next0
|
||||
assert not any(i.get("id") == SENTINEL_ID or i.get("resourceId") == SENTINEL_ID
|
||||
for i in fut_store.STORE.items())
|
||||
print("C economy safety (65534 non-openable / non-granting): PASS")
|
||||
|
||||
print("empty My Packs compatibility: PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Read the FIFA 17 TRADING gate byte out of the live client. READ-ONLY.
|
||||
|
||||
Extends tools/gate_byte_probe.py with vtable slot +0x270 (IS_TRADING_ENABLED,
|
||||
displacement 0x1fd2e) plus the two pile-size dwords, which the transfer-market
|
||||
analysis names as the market screen's CardsDLL-supplied inputs.
|
||||
|
||||
Opens /proc/<pid>/mem O_RDONLY and preads. Nothing here can write.
|
||||
"""
|
||||
import os, struct
|
||||
|
||||
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, "FIFA17.exe not running"
|
||||
|
||||
base = None
|
||||
for ln in open('/proc/%d/maps' % pid):
|
||||
if 'CardsDLL' in ln:
|
||||
base = int(ln.split('-')[0], 16)
|
||||
assert base, "CardsDLL not mapped (client has not reached Ultimate Team)"
|
||||
slide = base - 0x180000000
|
||||
|
||||
fd = os.open('/proc/%d/mem' % pid, os.O_RDONLY)
|
||||
|
||||
|
||||
def rd(va, n):
|
||||
return os.pread(fd, n, va)
|
||||
|
||||
|
||||
# Control: the FNV atom-hash prologue must match the on-disk PE before any other
|
||||
# address is trusted.
|
||||
pe = open('/mnt/games/FIFA 17/CardsDLL_Win64_retail.dll', 'rb').read()
|
||||
|
||||
|
||||
def f(va):
|
||||
return va - 0x180000000 - 0x1000 + 0x400
|
||||
|
||||
|
||||
ok = pe[f(0x180180d00):f(0x180180d00) + 32] == rd(0x180180d00 + slide, 32)
|
||||
print("pid=%d slide=%#x FNV control=%s" % (pid, slide, "MATCH" if ok else "MISMATCH"))
|
||||
assert ok, "slide not proven; refusing to read further"
|
||||
|
||||
obj = struct.unpack('<Q', rd(0x1802e6398 + slide, 8))[0]
|
||||
vt = struct.unpack('<Q', rd(obj, 8))[0]
|
||||
print("model=%#x vtable(static)=%#x" % (obj, vt - slide))
|
||||
|
||||
SLOTS = [
|
||||
(0x270, 'IS_TRADING_ENABLED '),
|
||||
(0x2b0, 'IS_FRIENDLY_SEASON '),
|
||||
(0x2c8, 'IS_DRAFT_MODE '),
|
||||
(0x2e0, 'packOpeningAnimation '),
|
||||
]
|
||||
print("\n-- gate bytes decoded from their accessor stubs --")
|
||||
for off, name in SLOTS:
|
||||
slot = struct.unpack('<Q', rd(vt + off, 8))[0]
|
||||
stub = rd(slot, 8)
|
||||
if stub[:3] == b'\x0f\xb6\x81':
|
||||
disp = struct.unpack('<I', stub[3:7])[0]
|
||||
val = rd(obj + disp, 1)[0]
|
||||
print(" slot +%#05x %s disp=%#x VALUE=%d" % (off, name, disp, val))
|
||||
else:
|
||||
print(" slot +%#05x %s NOT a movzx stub: %s" % (off, name, stub.hex()))
|
||||
|
||||
print("\n-- market screen inputs --")
|
||||
for disp, name in [(0x1fd1c, 'TRADE_PILE_SIZE'), (0x1fd20, 'watchListSize '),
|
||||
(0x1fd2e, 'tradingEnabled '), (0x1fd2f, 'storeEnabled ')]:
|
||||
print(" model+%#x %s = %d" % (disp, name, rd(obj + disp, 1)[0]))
|
||||
|
||||
os.close(fd)
|
||||
@@ -11,7 +11,7 @@ Rules (from CardsDLL 0x18016D230 / 0x1801a33a0):
|
||||
* body must parse as JSON (else err 0x3E6); 204 + empty body is accepted.
|
||||
* [resp+0x1c] == 0 is the success test; 404 is OK only on the first user GET.
|
||||
"""
|
||||
import copy, datetime, json, os, random, re, sys, http.server
|
||||
import copy, datetime, json, os, random, re, sys, threading, time, http.server
|
||||
from urllib.parse import parse_qs, urlencode, urlsplit, urlunsplit
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
@@ -52,6 +52,173 @@ def visible_unopened_packs():
|
||||
return STORE.unopened_packs() + list(_OPENED_PACK_GRACE)
|
||||
|
||||
|
||||
# ---- FIFA17 empty-My-Packs capability negotiation (PER-SESSION, hardened) ----
|
||||
# The synthetic 65534 sentinel (store_catalog) is the universal P2 fallback. It is
|
||||
# suppressed for ONE FIFA session only when the launcher has registered that THAT
|
||||
# process positively verified the CardsDLL resolver guard (RVA 0x14858 == JG).
|
||||
#
|
||||
# BINDING: the authoritative key is the per-login-unique UTAS session id (X-UT-SID),
|
||||
# minted fresh at every /ut/auth and echoed by the client on every later call incl.
|
||||
# /store/purchasegroup (live-confirmed present on real store requests). The initial
|
||||
# prototype keyed on source IP ALONE; that was rejected because two FIFA processes
|
||||
# (concurrent or relaunched) share an IP, so an unverified process could inherit a
|
||||
# verified one's clean topology and crash. IP + persona are retained only as
|
||||
# auxiliary data: a fail-closed sid/ip sanity check and the (ip,persona) key for the
|
||||
# short-lived launcher->session hand-off.
|
||||
#
|
||||
# The launcher verifies out-of-band (autopatch) and cannot know the SID, so its
|
||||
# registration is staged as a SINGLE-USE, short-TTL PENDING keyed by (ip,persona)
|
||||
# and bound to exactly one FIFA session (directly if that session already exists,
|
||||
# else consumed at the session's login or its first store request). Fail-closed
|
||||
# everywhere: unknown / expired / absent / ambiguous / late => sentinel.
|
||||
# See docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md (§Session binding).
|
||||
FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION = 1
|
||||
FIFA17_MODE_SENTINEL = "sentinel"
|
||||
FIFA17_MODE_CLEAN = "clean-v1"
|
||||
FIFA17_SESSION_TTL = 3600.0 # reap a FIFA session after this many idle seconds
|
||||
FIFA17_PENDING_TTL = 120.0 # a launcher capability may await its session this long
|
||||
|
||||
# sid -> {"ip","persona","resolver": Optional[int],"mode": Optional[str],"created","last_seen"}
|
||||
_FIFA17_SESSIONS = {}
|
||||
# (ip, persona) -> {"resolver": int, "ts"}: single-use launcher->session hand-off.
|
||||
_FIFA17_PENDING = {}
|
||||
_FIFA17_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _fifa17_now():
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _fifa17_client_ip(h):
|
||||
"""Peer IP for the handler, or None when unavailable (e.g. h is None)."""
|
||||
try:
|
||||
return h.client_address[0]
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _fifa17_sid(h):
|
||||
"""The client's UTAS session id (X-UT-SID) for this request, or None."""
|
||||
try:
|
||||
return h.headers.get("X-UT-SID")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _fifa17_sidlog(sid):
|
||||
"""A short, non-secret tag for correlating a session in logs."""
|
||||
return ("\u2026" + sid[-6:]) if sid else "-"
|
||||
|
||||
|
||||
def _fifa17_mint_sid():
|
||||
"""A fresh, per-login-unique UTAS session id (same shape/length as the legacy
|
||||
constant). Uniqueness -- not unpredictability -- is what the binding needs."""
|
||||
return "OPENFUT-SID-%016X" % random.getrandbits(64)
|
||||
|
||||
|
||||
def _fifa17_reap_locked(now):
|
||||
for sid in [s for s, r in _FIFA17_SESSIONS.items()
|
||||
if now - r["last_seen"] > FIFA17_SESSION_TTL]:
|
||||
del _FIFA17_SESSIONS[sid]
|
||||
for key in [k for k, p in _FIFA17_PENDING.items()
|
||||
if now - p["ts"] > FIFA17_PENDING_TTL]:
|
||||
del _FIFA17_PENDING[key]
|
||||
|
||||
|
||||
def _fifa17_take_pending_locked(ip, persona, now):
|
||||
"""Single-use: remove and return a fresh pending resolver for (ip,persona)."""
|
||||
p = _FIFA17_PENDING.get((ip, persona))
|
||||
if p is not None and now - p["ts"] <= FIFA17_PENDING_TTL:
|
||||
del _FIFA17_PENDING[(ip, persona)]
|
||||
return p["resolver"]
|
||||
return None
|
||||
|
||||
|
||||
def fifa17_session_known(sid):
|
||||
"""True if sid is a live session (or the legacy constant, accepted by the
|
||||
retired security-question gate ONLY -- never used to grant clean store mode)."""
|
||||
if sid == SID:
|
||||
return True
|
||||
with _FIFA17_LOCK:
|
||||
return sid in _FIFA17_SESSIONS
|
||||
|
||||
|
||||
def fifa17_open_session(sid, ip, persona):
|
||||
"""/ut/auth: open a per-login session and bind any pending launcher capability
|
||||
for (ip,persona) that arrived before login."""
|
||||
if not sid:
|
||||
return
|
||||
now = _fifa17_now()
|
||||
with _FIFA17_LOCK:
|
||||
_fifa17_reap_locked(now)
|
||||
resolver = _fifa17_take_pending_locked(ip, persona, now)
|
||||
_FIFA17_SESSIONS[sid] = {"ip": ip, "persona": persona, "resolver": resolver,
|
||||
"mode": None, "created": now, "last_seen": now}
|
||||
log("[fifa17-store] session opened %s (ip=%s persona=%s resolver=%s)"
|
||||
% (_fifa17_sidlog(sid), ip, persona, resolver))
|
||||
|
||||
|
||||
def fifa17_clear_pending(ip):
|
||||
"""/openfut/account/sync hygiene: drop any stale pending for this machine so a
|
||||
new launch's unverified session cannot inherit a leftover capability."""
|
||||
now = _fifa17_now()
|
||||
with _FIFA17_LOCK:
|
||||
_fifa17_reap_locked(now)
|
||||
for key in [k for k in _FIFA17_PENDING if k[0] == ip]:
|
||||
del _FIFA17_PENDING[key]
|
||||
|
||||
|
||||
def fifa17_register_capability(ip, persona, version):
|
||||
"""Launcher registration. Returns one of:
|
||||
"bound" exactly one live, unfrozen, unbound session for (ip,persona)
|
||||
existed (registration after login -- the common case): bound now.
|
||||
"pending" no session for (ip,persona) yet (before login): staged single-use.
|
||||
"ignored-late" a session for (ip,persona) exists but is frozen or ambiguous
|
||||
(>1 unbound): NOT staged, so no later/unverified process can
|
||||
inherit it. Fail-closed.
|
||||
Never authorizes more than one session."""
|
||||
now = _fifa17_now()
|
||||
with _FIFA17_LOCK:
|
||||
_fifa17_reap_locked(now)
|
||||
sessions = [r for r in _FIFA17_SESSIONS.values()
|
||||
if r["ip"] == ip and r["persona"] == persona]
|
||||
candidates = [r for r in sessions if r["mode"] is None and r["resolver"] is None]
|
||||
if len(candidates) == 1:
|
||||
candidates[0]["resolver"] = version
|
||||
return "bound"
|
||||
if sessions:
|
||||
return "ignored-late"
|
||||
_FIFA17_PENDING[(ip, persona)] = {"resolver": version, "ts": now}
|
||||
return "pending"
|
||||
|
||||
|
||||
def fifa17_empty_mypacks_mode(sid, ip):
|
||||
"""Freeze (once) and return the empty-My-Packs mode for FIFA session `sid`.
|
||||
Freeze point = the first /store/purchasegroup of the session. Fail-closed: an
|
||||
unknown session, or a sid presented from a different IP than it was opened on,
|
||||
resolves to the sentinel."""
|
||||
now = _fifa17_now()
|
||||
with _FIFA17_LOCK:
|
||||
_fifa17_reap_locked(now)
|
||||
rec = _FIFA17_SESSIONS.get(sid)
|
||||
if rec is None:
|
||||
return FIFA17_MODE_SENTINEL
|
||||
rec["last_seen"] = now
|
||||
if rec["ip"] is not None and ip is not None and rec["ip"] != ip:
|
||||
log("[fifa17-store] sid %s ip mismatch (session %s != request %s) -> sentinel"
|
||||
% (_fifa17_sidlog(sid), rec["ip"], ip))
|
||||
return FIFA17_MODE_SENTINEL
|
||||
if rec["mode"] is None:
|
||||
if rec["resolver"] is None:
|
||||
rec["resolver"] = _fifa17_take_pending_locked(rec["ip"], rec["persona"], now)
|
||||
rec["mode"] = (FIFA17_MODE_CLEAN
|
||||
if rec["resolver"] == FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION
|
||||
else FIFA17_MODE_SENTINEL)
|
||||
log("[fifa17-store] session %s empty-mypacks mode frozen: %s"
|
||||
% (_fifa17_sidlog(sid), rec["mode"]))
|
||||
return rec["mode"]
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
@@ -102,7 +269,7 @@ def security_question_route(h):
|
||||
well-formed value without retaining or comparing it. Account selection has
|
||||
already initialized the server-owned verified compatibility state.
|
||||
"""
|
||||
if h.headers.get("X-UT-SID") != SID:
|
||||
if not fifa17_session_known(h.headers.get("X-UT-SID")):
|
||||
log("[FUT] security-question request has no matching OpenFUT session")
|
||||
return 400, {"reason": "invalid_session"}
|
||||
|
||||
@@ -193,11 +360,19 @@ def auth_body(h=None):
|
||||
except Exception as e: # adoption must never break auth
|
||||
log(" AUTH: adopt failed (%s: %s) -- keeping %s/%r"
|
||||
% (type(e).__name__, e, before[0], before[1]))
|
||||
return {"protocol": 1, "sid": SID, "serverTime": now(), "lastOnlineTime": now()}
|
||||
sid = _fifa17_mint_sid()
|
||||
fifa17_open_session(sid, _fifa17_client_ip(h), ACCOUNT.persona_id)
|
||||
return {"protocol": 1, "sid": sid, "serverTime": now(), "lastOnlineTime": now()}
|
||||
|
||||
|
||||
def account_sync_route(h):
|
||||
"""Launcher-only active-profile selection, before LSX/Blaze login starts."""
|
||||
# Pre-launch hygiene: drop any stale launcher capability still pending for this
|
||||
# machine so a new launch's unverified FIFA session cannot inherit it. The real
|
||||
# per-process session is opened later, at /ut/auth (keyed by the minted X-UT-SID).
|
||||
ip = _fifa17_client_ip(h)
|
||||
fifa17_clear_pending(ip)
|
||||
log(" ACCOUNT: cleared stale FIFA17 pending capability for ip %s" % ip)
|
||||
try:
|
||||
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
|
||||
account = activate_account(body)
|
||||
@@ -209,6 +384,33 @@ def account_sync_route(h):
|
||||
return 200, {"account": account, "status": "OK"}
|
||||
|
||||
|
||||
def fifa17_capability_route(h):
|
||||
"""POST /openfut/fifa17/capability -- launcher registers a verified resolver
|
||||
capability for the current FIFA process (bound to the peer IP). Fail-closed:
|
||||
anything but capability==empty_mypacks_resolver && version==current is a 400
|
||||
that records NOTHING (the session stays on the sentinel fallback)."""
|
||||
try:
|
||||
body = json.loads(h._body.decode("utf-8")) if getattr(h, "_body", b"") else {}
|
||||
except Exception:
|
||||
return 400, {"error": "unsupported capability"}
|
||||
if not isinstance(body, dict):
|
||||
return 400, {"error": "unsupported capability"}
|
||||
try:
|
||||
version = int(body.get("version"))
|
||||
except (TypeError, ValueError):
|
||||
return 400, {"error": "unsupported capability"}
|
||||
if (body.get("capability") != "empty_mypacks_resolver"
|
||||
or version != FIFA17_EMPTY_MYPACKS_RESOLVER_VERSION):
|
||||
return 400, {"error": "unsupported capability"}
|
||||
ip = _fifa17_client_ip(h)
|
||||
persona = body.get("personaId")
|
||||
fifa_pid = body.get("fifaPid", "?")
|
||||
status = fifa17_register_capability(ip, persona, version)
|
||||
log("[fifa17-store] capability empty_mypacks_resolver=%s ip=%s persona=%s "
|
||||
"fifa_pid=%s -> %s" % (version, ip, persona, fifa_pid, status))
|
||||
return 200, {"status": "OK"}
|
||||
|
||||
|
||||
def current_squad():
|
||||
"""The squad the client should see: the persisted one (item refs re-embedded
|
||||
from the club) or the seed ladder squad on first run.
|
||||
@@ -1203,6 +1405,10 @@ ROUTES = [
|
||||
# Launcher control-plane endpoint. It is intentionally outside /ut so FIFA
|
||||
# never calls it; launch is blocked unless this succeeds first.
|
||||
(re.compile(r"^/openfut/account/sync$"), lambda m, h: account_sync_route(h)),
|
||||
# Launcher registers a verified per-FIFA-process resolver capability (bound to
|
||||
# peer IP). Adjacent to account/sync, above the generic /ut routes; FIFA never
|
||||
# calls it. Fail-closed: absent/late/wrong-version => sentinel (store_catalog).
|
||||
(re.compile(r"^/openfut/fifa17/capability$"), lambda m, h: fifa17_capability_route(h)),
|
||||
# ---- FUT item-definition endpoints (must precede generic /item, /user) ----
|
||||
(re.compile(G + r"/item/resource"), lambda m, h: defs_route(h)),
|
||||
(re.compile(G + r"/defid"), lambda m, h: defs_route(h)),
|
||||
@@ -3426,12 +3632,42 @@ def store_catalog(h):
|
||||
if owned:
|
||||
packs.append(_pack_body(owned, idx, owned=True))
|
||||
if not owned_ids:
|
||||
# GOTO_STORE_MYPACK resolves the hard-coded `mypacks` group before it
|
||||
# renders rows. If the group is absent FIFA falls back to Bronze and
|
||||
# shows the empty-category dialog over the wrong tab. Retain an inactive
|
||||
# zero-item sentinel so the destination resolves, while state != active
|
||||
# keeps it out of the visible row list. Its id is deliberately absent
|
||||
# from PACK_CATALOG, so both purchase/open handlers reject it as well.
|
||||
# ADDITIVE capability switch (see docs/plans/FIFA17_PATCHED_CLIENT_CAPABILITY.md
|
||||
# §7/§9). This is the session-freeze point: the empty-mypacks decision for
|
||||
# this FIFA session (keyed by its X-UT-SID) is committed here at the first
|
||||
# /store/purchasegroup and is immutable for the session thereafter.
|
||||
mode = fifa17_empty_mypacks_mode(_fifa17_sid(h), _fifa17_client_ip(h))
|
||||
if mode == FIFA17_MODE_CLEAN:
|
||||
# Verified patched client: emit NO mypacks group; the CardsDLL resolver
|
||||
# guard (RVA 0x14858 JG) routes the -1 ordinal to Browse instead of
|
||||
# dereferencing a null group. (append nothing)
|
||||
pass
|
||||
else:
|
||||
# EMPTY MY PACKS -- FIFA 17 client-compatibility workaround (bug 6c, P2).
|
||||
#
|
||||
# The Store/Scaleform path RESOLVES the `mypacks` category even when the
|
||||
# account owns zero unopened packs (the category is chosen client-side from
|
||||
# the movie's CATEGORY_ID -> screen+0x290; no server field gates it).
|
||||
# CardsDLL FUN_1800147f0 then dereferences the resolved group with NO null
|
||||
# guard, so if no `mypacks` group exists the client CRASHES
|
||||
# (CardsDLL_Win64_retail.dll+0x14882, read of [NULL+0x48] -- confirmed by
|
||||
# minidump). We therefore MUST emit a `mypacks` group when empty.
|
||||
#
|
||||
# state="inactive" avoids the crash but makes the client report the pack
|
||||
# unavailable immediately on Store entry and bounce to the Hub. state="active"
|
||||
# keeps the group structurally valid AND lets the Store open normally; the
|
||||
# empty tile renders as "0 items" and an explicit open is rejected
|
||||
# CLIENT-SIDE ("This pack is no longer available") -- it sends NO backend
|
||||
# request and mutates nothing.
|
||||
#
|
||||
# id 65534 is deliberately ABSENT from PACK_CATALOG, so pack_by_id() returns
|
||||
# None and store_buy()/purchased_items() cannot open it, grant items/coins,
|
||||
# or add it to unopenedPackIds. This is a compatibility shim for FIFA 17
|
||||
# client behavior, NOT an EA-authentic empty-My-Packs representation, and it
|
||||
# is FIFA17-specific (do not lift into game-independent Core). A fully clean
|
||||
# zero-pack UX requires a client-side fix -- see
|
||||
# docs/plans/FIFA17_EMPTY_MYPACKS_CLIENT_FIX.md and the evidence in
|
||||
# docs/evidence/STORE_TILE_6C.md / FIFA17_EMPTY_MYPACKS_CLIENT_CONTRACT.md.
|
||||
sentinel = {
|
||||
"id": 65534,
|
||||
"name": "",
|
||||
@@ -3441,7 +3677,7 @@ def store_catalog(h):
|
||||
"specialChance": 0.0,
|
||||
}
|
||||
empty = _pack_body(sentinel, 1, owned=True)
|
||||
empty["state"] = "inactive"
|
||||
empty["state"] = "active"
|
||||
empty["unopened"] = False
|
||||
packs.append(empty)
|
||||
return 200, {"purchase": packs, "timestamp": 1596326400}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
[package]
|
||||
name = "openfut-adapter-fifa17"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "FIFA 17 game adapter: Blaze command tables, response bodies and dispatch"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
openfut-protocol-blaze = { path = "../openfut-protocol-blaze" }
|
||||
# Reads the bundled fetchClientConfig table (227-243 rows per CFID), which is
|
||||
# generated from the Python oracle rather than transcribed by hand. Unlike the
|
||||
# protocol crate below it, this crate is ordinary server-side code, so a real
|
||||
# JSON parser is the right call — hand-rolling one to preserve a zero-dependency
|
||||
# streak would be reinventing a solved problem in the riskiest possible place.
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
# Seeded RNG for the Store pack-content generator (`fut::pack_content`). The
|
||||
# generator is pure over an injected `rand::Rng`, so packs are deterministic
|
||||
# under a seeded `StdRng` in tests and reproducible in production.
|
||||
rand = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
# Differential fixtures are JSONL; the runtime dependency already covers it.
|
||||
@@ -0,0 +1,115 @@
|
||||
# openfut-adapter-fifa17
|
||||
|
||||
The FIFA 17 game adapter. Everything true of *FIFA 17 specifically* lives here,
|
||||
so that neither OpenFUT Core nor the generic protocol crates have to know about
|
||||
it.
|
||||
|
||||
```
|
||||
openfut-protocol-blaze generic Blaze: Fire2 framing, Heat2/TDF codec
|
||||
▲
|
||||
openfut-adapter-fifa17 THIS: command tables, response bodies, dispatch order
|
||||
▲
|
||||
OpenFUT Core game-independent FUT domain (not yet wired)
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
| Surface | Port | State |
|
||||
|---|---|---|
|
||||
| **Blaze / Fire2 RPC** | 42130 | **Implemented**, byte-for-byte parity-tested |
|
||||
| Redirector (HTTPS + XML) | 42127 | Python only |
|
||||
| Nucleus OAuth stub | 42131 | Python only |
|
||||
| LSX / Origin | 4216 | Python only |
|
||||
| Roster XML | 8081 | Python only |
|
||||
| UTAS / RS4 | 8099 | Python only |
|
||||
| POW / EASFC | 8094 / 8080 | Python only |
|
||||
|
||||
**Nothing here is wired into the running backend.** The crate answers frames; it
|
||||
opens no socket, terminates no TLS and owns no runtime. The Python backend
|
||||
remains the live service and the behavioural oracle.
|
||||
|
||||
## What the adapter owns, and what it must not
|
||||
|
||||
Owns: component/command/notification IDs, response body shapes, dispatch
|
||||
ordering, session identity, the `fetchClientConfig` tables.
|
||||
|
||||
Must not own: FUT domain state. Blaze is an auth/session/config protocol — no
|
||||
coins, packs, clubs or squads appear on this wire — so `Session` holds a session
|
||||
key, a locale, a service name, an auth code and a flag, and that is all. When
|
||||
UTAS is migrated that boundary will need active defending; here it comes free.
|
||||
|
||||
## Parity
|
||||
|
||||
```bash
|
||||
./check-parity.sh # oracle freshness + byte-for-byte replay
|
||||
./check-parity.sh --regen # after an intentional oracle change
|
||||
```
|
||||
|
||||
`fixtures/blaze_transactions.jsonl` holds 49 request→response(s) transactions
|
||||
produced by calling the real `blaze_responder_v3b.dispatch()`. They replay in
|
||||
order against a shared session per connection, so ordering-dependent behaviour
|
||||
is exercised rather than assumed: preAuth captures the locale that later `ALOC`
|
||||
fields echo, and login sets the auth code `getAuthToken` returns afterwards.
|
||||
|
||||
Comparison is byte-for-byte including frame count and order — a missing
|
||||
post-login notification or a reply where the oracle stays silent fails here.
|
||||
|
||||
The suite was **mutation-tested**: swapping two post-login notifications,
|
||||
flipping one enum deep inside `AccountInfo`, and hardcoding an address in
|
||||
`utas_base()`/`nucleus_base()` were each verified to turn it red. The third
|
||||
initially did *not*, because the config templating had made those helpers dead
|
||||
code; the table now templates on URL-level tokens so they are the single place a
|
||||
URL shape is defined.
|
||||
|
||||
## Three behaviours that are easy to get wrong
|
||||
|
||||
* **Login answers with four frames, in order**: reply, then `UserAuthenticated`,
|
||||
`UserSessionExtendedDataUpdate`, `UserAdded`.
|
||||
* **An unimplemented RPC still gets an empty reply.** Silence makes the client
|
||||
wait for a timeout; an empty reply lets every field fall back to a client-side
|
||||
default and the boot continues.
|
||||
* **Non-request message types get nothing at all.**
|
||||
|
||||
No error replies are emitted. `msgType` 3 exists, but the error-code placement
|
||||
is UNRESOLVED — three clean-room sources disagree between `header[14:16]`, a
|
||||
metadata `ERRC`, and a payload `CNTX`/`ERRC` — so emitting one would be a guess
|
||||
on the wire.
|
||||
|
||||
## The client config table
|
||||
|
||||
`fixtures/client_config.json` carries 227–243 rows per CFID, generated from the
|
||||
Python oracle and templated on `{utas_base}`, `{nucleus_base}`,
|
||||
`{pow_content_url}`, `{advertise}`, `{bind}`, `{pow_host}`. It is
|
||||
reverse-engineered *data*, not logic, and deriving it mechanically removes a
|
||||
class of transcription typo no reviewer could catch. The generator does not take
|
||||
its own templating on trust: it substitutes real addresses back in and diffs
|
||||
against the oracle for every section before writing the file.
|
||||
|
||||
The table must be *complete*, not representative. The client resolves a per-call
|
||||
key (`FUT_RS4_URL_<CALL>`) before a per-module one, and any unresolved call falls
|
||||
back to a real, dead EA host — that is what produced "there has been an error
|
||||
connecting to FIFA 17 Ultimate Team" mid-session when only the boot subset was
|
||||
served.
|
||||
|
||||
## Known defect reproduced deliberately
|
||||
|
||||
`nucleusConnect` and `nucleusConnectTrusted` are built from the **bind** address,
|
||||
not the advertised one. On the live split deployment that means the backend
|
||||
tells a client on another machine to reach Nucleus at `http://0.0.0.0:42131`,
|
||||
which it cannot. Verified against the running container, not inferred.
|
||||
|
||||
This is reproduced exactly, because it is what the only proven-working
|
||||
configuration does and changing it would break parity. It also implies the
|
||||
Nucleus stub is not actually reached in the current remote flow. Fixing it is a
|
||||
separate change that needs live validation — see the vault.
|
||||
|
||||
## Configuration
|
||||
|
||||
Nothing is hardcoded. `AdapterConfig` carries `Identity` (persona, ids, email,
|
||||
namespace, entitlement group, …) and `Endpoints` (advertise, bind, POW hosts,
|
||||
telemetry/ticker/QoS ports). `Default` gives the project's synthetic offline
|
||||
identity on loopback; a remote deployment must override `advertise`.
|
||||
|
||||
Bind and advertise are deliberately distinct: an advertised URL must carry the
|
||||
address the *client* can reach, which on a two-machine deployment is not the
|
||||
address the server binds.
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Differential check: the Rust FIFA 17 Blaze adapter vs the Python responder.
|
||||
#
|
||||
# 1. assert the committed fixtures still match what the Python oracle emits
|
||||
# 2. replay every recorded transaction through the Rust adapter, byte-for-byte
|
||||
#
|
||||
# Read-only with respect to the running backend: the oracle is imported as a
|
||||
# library, no responder is started, no port is bound, no live service is
|
||||
# touched. Safe to run while the Python backend is serving a live FIFA client.
|
||||
#
|
||||
# Use --regen to rewrite the fixtures after an intentional oracle change.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$(readlink -f "$0")")"
|
||||
|
||||
if [[ "${1:-}" == "--regen" ]]; then
|
||||
echo "==> regenerating fixtures from the Python oracle"
|
||||
python3 fixtures/generate.py
|
||||
else
|
||||
echo "==> checking committed fixtures against the Python oracle"
|
||||
python3 fixtures/generate.py --check
|
||||
fi
|
||||
|
||||
echo "==> replaying transactions through the Rust adapter"
|
||||
cargo test -p openfut-adapter-fifa17
|
||||
|
||||
echo
|
||||
echo "PARITY OK — the adapter reproduces the Python dispatcher byte-for-byte."
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,460 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Freeze the Python Blaze responder's DISPATCH contract as replayable fixtures.
|
||||
|
||||
The crate-level fixtures in `openfut-protocol-blaze` pin the *codec*: given a
|
||||
field tree, what bytes come out. This file pins the layer above: given an
|
||||
inbound Fire2 frame and a session, **which frames go back, in what order**.
|
||||
|
||||
That is the whole contract of a Blaze adapter, and it is the thing a rewrite can
|
||||
silently get wrong in ways a codec test cannot see — a missing post-login
|
||||
notification, a reply where the oracle stays silent, notifications in the wrong
|
||||
order, session state not carried between RPCs.
|
||||
|
||||
Every transaction is produced by calling the real
|
||||
`blaze_responder_v3b.dispatch()`. Session state is threaded across a scripted
|
||||
connection exactly as it would be on a live socket, so ordering-dependent
|
||||
behaviour (preAuth captures the locale; login sets the auth code that
|
||||
getAuthToken later returns) is captured rather than assumed.
|
||||
|
||||
Determinism: the oracle's clock is pinned and its PRNG seeded, and the
|
||||
deployment-dependent addresses are set before import (the responder reads them
|
||||
at import time). See the sibling generator in openfut-protocol-blaze.
|
||||
|
||||
NO SECRETS. The identity here (persona 33068179 / "CAGE") is the project's fixed
|
||||
synthetic offline identity. Session keys are minted from a seeded PRNG.
|
||||
|
||||
Usage: python3 fixtures/generate.py (write)
|
||||
python3 fixtures/generate.py --check (verify committed files are current)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import sys
|
||||
from collections import OrderedDict
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
TOOLS = os.path.normpath(os.path.join(HERE, "..", "..", "fifa17-recon", "tools"))
|
||||
if not os.path.isdir(TOOLS):
|
||||
sys.exit("cannot find the Python oracle at %s" % TOOLS)
|
||||
sys.path.insert(0, TOOLS)
|
||||
|
||||
CHECK_ONLY = "--check" in sys.argv[1:]
|
||||
# Internal mode: re-exec of this script with sentinel addresses, used to derive
|
||||
# the templated client-config table (see emit_config_table).
|
||||
CONFIG_TABLE_MODE = "--_config_table" in sys.argv[1:]
|
||||
sys.argv = [sys.argv[0]]
|
||||
|
||||
# Sentinels substituted back into template tokens. Deliberately not IP-shaped so
|
||||
# a stray literal cannot be mistaken for a real address.
|
||||
SENTINELS = [
|
||||
("ADVERTISE-SENTINEL", "{advertise}"),
|
||||
("BIND-SENTINEL", "{bind}"),
|
||||
("POWCONTENT-SENTINEL", "{pow_content_host}"),
|
||||
("POWHOST-SENTINEL", "{pow_host}"),
|
||||
]
|
||||
|
||||
if CONFIG_TABLE_MODE:
|
||||
os.environ["OPENFUT_ADVERTISE"] = "ADVERTISE-SENTINEL"
|
||||
os.environ["OPENFUT_BIND"] = "BIND-SENTINEL"
|
||||
os.environ["POW_CONTENT_HOST"] = "POWCONTENT-SENTINEL"
|
||||
os.environ["POW_HOST"] = "POWHOST-SENTINEL"
|
||||
import blaze_responder_v3b as _B # noqa: E402
|
||||
out = {cfid: _B.client_config_for(cfid) for cfid in sorted(_B.CLIENT_CONFIGS)}
|
||||
out["__default__"] = _B.client_config_for("__no_such_section__")
|
||||
print(json.dumps(out))
|
||||
raise SystemExit(0)
|
||||
|
||||
# Pin deployment config BEFORE import — the responder snapshots these at import
|
||||
# time into module globals used by the response builders.
|
||||
#
|
||||
# Distinct, obviously-fake values on purpose: if the Rust adapter hardcoded an
|
||||
# address instead of reading its config, these make the failure loud rather than
|
||||
# accidentally matching a loopback default.
|
||||
ADVERTISE = "198.51.100.7"
|
||||
BIND = "0.0.0.0"
|
||||
POW_CONTENT_HOST = "198.51.100.7:8085"
|
||||
POW_HOST = "198.51.100.7:8094"
|
||||
|
||||
os.environ["OPENFUT_ADVERTISE"] = ADVERTISE
|
||||
os.environ["OPENFUT_BIND"] = BIND
|
||||
os.environ["POW_CONTENT_HOST"] = POW_CONTENT_HOST
|
||||
os.environ["POW_HOST"] = POW_HOST
|
||||
|
||||
import heat2 # noqa: E402
|
||||
import blaze_responder_v3b as B # noqa: E402
|
||||
from fut_account import ACCOUNT # noqa: E402
|
||||
|
||||
FIXED_NOW = 1754870400
|
||||
INT, STRING, STRUCT, LIST, MAP, BLOB = (
|
||||
heat2.INT, heat2.STRING, heat2.STRUCT, heat2.LIST, heat2.MAP, heat2.BLOB)
|
||||
|
||||
RECORDS = []
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ helpers
|
||||
|
||||
def req_frame(component, command, fields=None, msg_num=1, msg_type=None,
|
||||
user_index=0):
|
||||
"""Build an inbound request frame the way the client would."""
|
||||
msg_type = B.MESSAGE if msg_type is None else msg_type
|
||||
payload = heat2.encode_tdf(fields) if fields else b""
|
||||
return B.fire2(component, command, msg_num, msg_type, payload,
|
||||
user_index=user_index)
|
||||
|
||||
|
||||
def tx(session, name, frame, note=""):
|
||||
"""Run one frame through the real dispatcher and record what came back."""
|
||||
hdr = B.parse_fire2_header(frame)
|
||||
body = frame[16 + hdr["metadata_len"]:]
|
||||
fields = heat2.decode_tdf(body) if body else OrderedDict()
|
||||
out = B.dispatch(hdr, fields, body, session["sess"])
|
||||
|
||||
RECORDS.append(OrderedDict((
|
||||
("kind", "tx"),
|
||||
("session", session["id"]),
|
||||
("name", name),
|
||||
("note", note),
|
||||
("request_hex", frame.hex()),
|
||||
("responses", [f.hex() for f in out]),
|
||||
)))
|
||||
return out
|
||||
|
||||
|
||||
def new_session(sid):
|
||||
s = {"id": sid, "sess": B.Session()}
|
||||
RECORDS.append(OrderedDict((
|
||||
("kind", "session"),
|
||||
("id", sid),
|
||||
# Minted per connection by the oracle; the Rust side must be able to
|
||||
# inject it, because it appears in LoginResponse.SESS.KEY, the
|
||||
# UserAuthenticated push and PostAuthResponse.TELE.SESS and all three
|
||||
# must be the same string.
|
||||
("session_key", s["sess"].session_key),
|
||||
("account_locale", s["sess"].account_locale),
|
||||
("service_name", s["sess"].service_name),
|
||||
)))
|
||||
return s
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ script
|
||||
|
||||
def build():
|
||||
RECORDS.append(OrderedDict((
|
||||
("kind", "config"),
|
||||
("advertise", ADVERTISE),
|
||||
("bind", BIND),
|
||||
("pow_content_host", POW_CONTENT_HOST),
|
||||
("pow_host", POW_HOST),
|
||||
("now", FIXED_NOW),
|
||||
("identity", OrderedDict((
|
||||
("persona_id", ACCOUNT.persona_id),
|
||||
("persona_name", ACCOUNT.persona_name),
|
||||
("user_id", ACCOUNT.user_id),
|
||||
("ext_id", ACCOUNT.ext_id),
|
||||
("email", ACCOUNT.email),
|
||||
("namespace", ACCOUNT.NAMESPACE),
|
||||
("client_platform", ACCOUNT.CLIENT_PLATFORM),
|
||||
("persona_status", ACCOUNT.PERSONA_STATUS),
|
||||
("user_session_type", ACCOUNT.USER_SESSION_TYPE),
|
||||
("account_locale_int", ACCOUNT.account_locale_int),
|
||||
("locale", ACCOUNT.locale),
|
||||
("content_id", ACCOUNT.CONTENT_ID),
|
||||
("entitlement_tag", ACCOUNT.ENTITLEMENT_TAG),
|
||||
("entitlement_group", ACCOUNT.ENTITLEMENT_GROUP),
|
||||
("title_id", ACCOUNT.TITLE_ID),
|
||||
("client_id", ACCOUNT.CLIENT_ID),
|
||||
("platform", ACCOUNT.PLATFORM),
|
||||
("server_version", B.SERVER_VERSION),
|
||||
))),
|
||||
)))
|
||||
|
||||
# ================= main connection: the real boot order =================
|
||||
#
|
||||
# Mirrors what FIFA 17 actually does, because ordering is load-bearing:
|
||||
# preAuth captures the locale that later ALOC fields echo, and login sets
|
||||
# the auth code that getAuthToken returns afterwards.
|
||||
m = new_session("main")
|
||||
|
||||
tx(m, "preauth", req_frame(B.COMP_UTIL, B.CMD_PREAUTH, OrderedDict([
|
||||
("CDAT", (STRUCT, OrderedDict([
|
||||
("IITO", (INT, 0)),
|
||||
("LANG", (INT, 0x656E5553)), # 'enUS'
|
||||
("SVCN", (STRING, "fifa-2017-pc")), # echoed back as INST
|
||||
("TYPE", (INT, 0)),
|
||||
]))),
|
||||
("CINF", (STRUCT, OrderedDict([
|
||||
("BSDK", (STRING, "15.1.1.3.0")),
|
||||
("CLNT", (STRING, "FIFA17")),
|
||||
("ENV", (STRING, "prod")),
|
||||
("LOC", (INT, 0x656E5553)),
|
||||
]))),
|
||||
("FCCR", (STRUCT, OrderedDict([("CFID", (STRING, "BlazeSDK"))]))),
|
||||
])), "first RPC; echoes SVCN as INST and captures LANG for ALOC")
|
||||
|
||||
tx(m, "ping", req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=2),
|
||||
"Util::ping -> STIM only")
|
||||
|
||||
# Every section the responder knows, plus unknown ones. The known sections
|
||||
# each add their own rows on top of the shared FUT/RS4 base — OSDK_ROSTER in
|
||||
# particular carries the roster URL, itself a documented loading gate — so
|
||||
# covering only "BlazeSDK" would leave most of the table unverified.
|
||||
for cfid in ("BlazeSDK", "netres", "IdentityParams", "OSDK_CORE",
|
||||
"OSDK_CLIENT", "OSDK_NUCLEUS", "OSDK_ROSTER", "OSDK_TICKER",
|
||||
"OSDK_WEBOFFER", "OSDK_POW", "OSDK_ABUSE_REPORTING",
|
||||
"OSDK_XMS_ABUSE_REPORTING", "UTAS", "FUT", "",
|
||||
"TOTALLY_UNKNOWN"):
|
||||
tx(m, "fetch_config_%s" % (cfid or "empty"),
|
||||
req_frame(B.COMP_UTIL, B.CMD_FETCHCLIENTCONFIG,
|
||||
OrderedDict([("CFID", (STRING, cfid))]), msg_num=3),
|
||||
"unknown CFIDs still get the shared FUT/POW rows")
|
||||
|
||||
tx(m, "get_auth_token_before_login",
|
||||
req_frame(B.COMP_AUTH, B.CMD_GETAUTHTOKEN, msg_num=4),
|
||||
"no auth code yet -> synthesised OPENFUT-<key[:16]> token")
|
||||
|
||||
tx(m, "logout_before_login", req_frame(B.COMP_AUTH, B.CMD_LOGOUT, msg_num=5),
|
||||
"routine LoginStateLogout (state 500), NOT a failure; empty reply")
|
||||
|
||||
tx(m, "login", req_frame(B.COMP_AUTH, B.CMD_LOGIN, OrderedDict([
|
||||
("AUTH", (STRING, "OPENFUT-TEST-AUTHCODE")),
|
||||
("EXTB", (BLOB, b"")),
|
||||
("PNAM", (STRING, "")),
|
||||
]), msg_num=6),
|
||||
"reply THEN three UserSessions pushes, in that order")
|
||||
|
||||
tx(m, "get_auth_token_after_login",
|
||||
req_frame(B.COMP_AUTH, B.CMD_GETAUTHTOKEN, msg_num=7),
|
||||
"now echoes the login's AUTH verbatim")
|
||||
|
||||
tx(m, "get_account", req_frame(B.COMP_AUTH, B.CMD_GETACCOUNT, msg_num=8),
|
||||
"the RPC behind 'Unable to retrieve account information'")
|
||||
tx(m, "get_persona", req_frame(B.COMP_AUTH, B.CMD_GETPERSONA, msg_num=9))
|
||||
tx(m, "list_personas", req_frame(B.COMP_AUTH, B.CMD_LISTPERSONAS, msg_num=10))
|
||||
|
||||
for cmd, label in ((B.CMD_LISTUSERENTITLEMENTS2, "listUserEntitlements2"),
|
||||
(0x20, "listEntitlements"),
|
||||
(0x30, "listPersonaEntitlements2"),
|
||||
(0x27, "grantEntitlement2")):
|
||||
tx(m, "entitlements_%s" % label,
|
||||
req_frame(B.COMP_AUTH, cmd, msg_num=11),
|
||||
"all four aliases return the same two ONLINE_ACCESS records")
|
||||
|
||||
tx(m, "post_auth", req_frame(B.COMP_UTIL, B.CMD_POSTAUTH, msg_num=12),
|
||||
"TELE/TICK/UROP; TELE.SESS must equal the login session key")
|
||||
tx(m, "fetch_qos_config", req_frame(B.COMP_UTIL, 0x15, msg_num=13))
|
||||
tx(m, "user_settings_load",
|
||||
req_frame(B.COMP_UTIL, B.CMD_USERSETTINGSLOAD, msg_num=14))
|
||||
tx(m, "user_settings_save",
|
||||
req_frame(B.COMP_UTIL, B.CMD_USERSETTINGSSAVE, msg_num=15),
|
||||
"accepted and discarded; empty reply")
|
||||
tx(m, "set_client_state",
|
||||
req_frame(B.COMP_UTIL, B.CMD_SETCLIENTSTATE, msg_num=16))
|
||||
tx(m, "set_client_metrics",
|
||||
req_frame(B.COMP_UTIL, B.CMD_SETCLIENTMETRICS, msg_num=17))
|
||||
|
||||
tx(m, "update_network_info",
|
||||
req_frame(B.COMP_USERSESSIONS, B.CMD_UPDATENETWORKINFO, msg_num=18),
|
||||
"empty reply PLUS an unsolicited ExtendedDataUpdate push")
|
||||
|
||||
tx(m, "get_lists", req_frame(B.COMP_ASSOCLISTS, B.CMD_GETLISTS, msg_num=19))
|
||||
|
||||
tx(m, "census_subscribe",
|
||||
req_frame(B.COMP_CENSUSDATA, B.CMD_SUBSCRIBETOCENSUSDATAUPDATES,
|
||||
OrderedDict([("RSUB", (INT, 1))]), msg_num=20),
|
||||
"non-zero TimeValues or the client storms at ~30/s and hangs the FUT load")
|
||||
|
||||
tx(m, "logout_after_login",
|
||||
req_frame(B.COMP_AUTH, B.CMD_LOGOUT, msg_num=21),
|
||||
"session teardown after a login; still an empty reply")
|
||||
|
||||
# ============================ fallback behaviour ========================
|
||||
f = new_session("fallbacks")
|
||||
|
||||
tx(f, "transport_ping",
|
||||
req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=30, msg_type=B.PING),
|
||||
"msgType PING -> PING_REPLY with an empty body, whatever the command")
|
||||
|
||||
for mt, label in ((B.REPLY, "reply"), (B.NOTIFICATION, "notification"),
|
||||
(B.ERROR_REPLY, "error_reply"),
|
||||
(B.PING_REPLY, "ping_reply")):
|
||||
tx(f, "ignores_%s" % label,
|
||||
req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=31, msg_type=mt),
|
||||
"not a request -> NO frames at all")
|
||||
|
||||
tx(f, "unknown_command",
|
||||
req_frame(B.COMP_UTIL, 0x0FFF, msg_num=32),
|
||||
"unimplemented RPC still gets an EMPTY reply so the client cannot hang")
|
||||
tx(f, "unknown_component",
|
||||
req_frame(0x1234, 0x0001, msg_num=33),
|
||||
"same fallback for an entirely unknown component")
|
||||
|
||||
tx(f, "user_index_is_echoed",
|
||||
req_frame(B.COMP_UTIL, B.CMD_PING, msg_num=34, user_index=7),
|
||||
"a reply echoes component/command/msgNum/userIndex verbatim")
|
||||
|
||||
# ================= locale echo on a non-default client ==================
|
||||
loc = new_session("locale")
|
||||
tx(loc, "preauth_de_locale",
|
||||
req_frame(B.COMP_UTIL, B.CMD_PREAUTH, OrderedDict([
|
||||
("CDAT", (STRUCT, OrderedDict([
|
||||
("LANG", (INT, 0x64654445)), # 'deDE'
|
||||
("SVCN", (STRING, "fifa-2017-pc-de")),
|
||||
]))),
|
||||
])),
|
||||
"a non-enUS client: SVCN echo AND the captured locale must both change")
|
||||
tx(loc, "login_with_de_locale",
|
||||
req_frame(B.COMP_AUTH, B.CMD_LOGIN, msg_num=41),
|
||||
"UserAuthenticated.ALOC must carry the captured deDE locale")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------- output
|
||||
|
||||
def emit_config_table():
|
||||
"""Derive the fetchClientConfig tables as address-TEMPLATED data.
|
||||
|
||||
These are 227-243 key/value rows per CFID, almost all of them the same URL.
|
||||
Hand-transcribing them into Rust would be 400 lines of string literals that
|
||||
nobody can review and one typo can break; deriving them mechanically from
|
||||
the oracle removes that whole class of error and keeps them regenerable.
|
||||
They are reverse-engineered *data*, not logic — the same reason
|
||||
`openfut-core` loads its content from `data/` rather than from source.
|
||||
|
||||
The values are templated on {advertise}/{bind}/{pow_content_host}/{pow_host}
|
||||
so the adapter stays configurable; baking an address in here would recreate
|
||||
exactly the hardcoding the client/server split removed.
|
||||
|
||||
Correctness is not assumed: the caller substitutes real addresses back in
|
||||
and diffs against the oracle. See verify_config_table.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
raw = subprocess.run(
|
||||
[sys.executable, os.path.abspath(__file__), "--_config_table"],
|
||||
capture_output=True, text=True, check=True,
|
||||
# Inherit nothing address-shaped; the child sets its own sentinels.
|
||||
env={k: v for k, v in os.environ.items()
|
||||
if not k.startswith(("OPENFUT_", "POW_", "FUT_"))},
|
||||
).stdout
|
||||
table = json.loads(raw)
|
||||
|
||||
def templatise(value):
|
||||
for sentinel, token in SENTINELS:
|
||||
value = value.replace(sentinel, token)
|
||||
# Collapse whole URLs to URL-level tokens where one exists, so the Rust
|
||||
# side builds them in exactly one place (AdapterConfig::utas_base and
|
||||
# friends) instead of re-deriving the shape here. Without this the
|
||||
# helpers become dead code and a hardcoded address in them goes
|
||||
# undetected — verified by mutation testing. Longest first.
|
||||
for whole, token in (
|
||||
("http://{advertise}:8099/", "{utas_base}"),
|
||||
("http://{bind}:42131", "{nucleus_base}"),
|
||||
("http://{pow_content_host}", "{pow_content_url}"),
|
||||
):
|
||||
if value == whole:
|
||||
return token
|
||||
return value
|
||||
|
||||
return {cfid: [[k, templatise(v)] for k, v in rows]
|
||||
for cfid, rows in table.items()}
|
||||
|
||||
|
||||
def verify_config_table(table):
|
||||
"""Substitute the real addresses back and require the oracle's exact rows.
|
||||
|
||||
This is what makes the templated table trustworthy rather than plausible.
|
||||
"""
|
||||
subst = {
|
||||
"{utas_base}": "http://%s:8099/" % ADVERTISE,
|
||||
"{nucleus_base}": "http://%s:42131" % BIND,
|
||||
"{pow_content_url}": "http://%s" % POW_CONTENT_HOST,
|
||||
"{advertise}": ADVERTISE,
|
||||
"{bind}": BIND,
|
||||
"{pow_content_host}": POW_CONTENT_HOST,
|
||||
"{pow_host}": POW_HOST,
|
||||
}
|
||||
|
||||
def render(v):
|
||||
for token, real in subst.items():
|
||||
v = v.replace(token, real)
|
||||
return v
|
||||
|
||||
for cfid, rows in table.items():
|
||||
expected = B.client_config_for(
|
||||
"__no_such_section__" if cfid == "__default__" else cfid)
|
||||
got = [(k, render(v)) for k, v in rows]
|
||||
if got != [(k, v) for k, v in expected]:
|
||||
for (gk, gv), (ek, ev) in zip(got, expected):
|
||||
if (gk, gv) != (ek, ev):
|
||||
sys.exit("config template mismatch in %s: %r -> %r, oracle "
|
||||
"has %r -> %r" % (cfid, gk, gv, ek, ev))
|
||||
sys.exit("config template row-count mismatch in %s: %d vs %d"
|
||||
% (cfid, len(got), len(expected)))
|
||||
print("config table verified against the oracle for %d sections"
|
||||
% len(table))
|
||||
|
||||
|
||||
def frozen_clock():
|
||||
import time as _time
|
||||
original = _time.time
|
||||
_time.time = lambda: float(FIXED_NOW)
|
||||
return original, _time
|
||||
|
||||
|
||||
def write(path, records):
|
||||
body = "".join(json.dumps(r, separators=(",", ":")) + "\n" for r in records)
|
||||
if CHECK_ONLY:
|
||||
if not os.path.exists(path):
|
||||
sys.exit("MISSING: %s has never been generated" % path)
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
if fh.read() != body:
|
||||
sys.exit("STALE: %s does not match the oracle; re-run without "
|
||||
"--check" % path)
|
||||
print("current: %s (%d records)" % (os.path.basename(path), len(records)))
|
||||
return
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(body)
|
||||
print("wrote %s (%d records)" % (os.path.basename(path), len(records)))
|
||||
|
||||
|
||||
def write_json(path, obj):
|
||||
body = json.dumps(obj, indent=1, sort_keys=True) + "\n"
|
||||
if CHECK_ONLY:
|
||||
if not os.path.exists(path):
|
||||
sys.exit("MISSING: %s has never been generated" % path)
|
||||
with open(path, "r", encoding="utf-8") as fh:
|
||||
if fh.read() != body:
|
||||
sys.exit("STALE: %s does not match the oracle" % path)
|
||||
print("current: %s" % os.path.basename(path))
|
||||
return
|
||||
with open(path, "w", encoding="utf-8") as fh:
|
||||
fh.write(body)
|
||||
print("wrote %s (%d sections)" % (os.path.basename(path), len(obj)))
|
||||
|
||||
|
||||
def main():
|
||||
# The oracle logs every dispatch to stdout; useful live, pure noise here.
|
||||
B.log = lambda *_a, **_k: None
|
||||
|
||||
table = emit_config_table()
|
||||
verify_config_table(table)
|
||||
write_json(os.path.join(HERE, "client_config.json"), table)
|
||||
|
||||
random.seed(0xB1A2E)
|
||||
original_time, time_mod = frozen_clock()
|
||||
try:
|
||||
build()
|
||||
finally:
|
||||
time_mod.time = original_time
|
||||
|
||||
write(os.path.join(HERE, "blaze_transactions.jsonl"), RECORDS)
|
||||
txs = [r for r in RECORDS if r["kind"] == "tx"]
|
||||
frames = sum(len(r["responses"]) for r in txs)
|
||||
print("%d transactions, %d response frames, %d sessions"
|
||||
% (len(txs), frames,
|
||||
len([r for r in RECORDS if r["kind"] == "session"])))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture the roster oracle's responses byte-for-byte.
|
||||
|
||||
generate_roster.py [--check] [host:port]
|
||||
|
||||
Unlike `generate.py`, which imports the Blaze responder and calls its pure
|
||||
functions, this captures over the wire. The roster response is shaped as much by
|
||||
`http.server.BaseHTTPRequestHandler` as by the handler code -- HTTP/1.0 status
|
||||
line, `Server:`/`Date:` injected ahead of the handler's own headers, POST
|
||||
answered without a body -- and only the real socket shows all of that.
|
||||
|
||||
Two fields are volatile and are MASKED rather than recorded:
|
||||
|
||||
Date: changes every second
|
||||
Server: carries the container's Python version
|
||||
|
||||
They are masked, not dropped, so their presence and position are still asserted.
|
||||
The Server string is additionally recorded verbatim under `observed_server`, so
|
||||
a drift between the container's Python and the adapter's `ORACLE_SERVER`
|
||||
constant is visible rather than silent.
|
||||
|
||||
`--check` re-captures and compares. If the oracle is unreachable it FAILS rather
|
||||
than passing: a check that cannot check must not report success.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import ssl
|
||||
import sys
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
OUT = os.path.join(HERE, "roster.json")
|
||||
PATH = "/fifa17/fut/rosterupdate.xml"
|
||||
|
||||
DATE_RE = re.compile(rb"^Date: .+?\r\n", re.M)
|
||||
SERVER_RE = re.compile(rb"^Server: (.+?)\r\n", re.M)
|
||||
|
||||
|
||||
def fetch(host, port, method, body=None):
|
||||
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
ctx.set_ciphers("ALL:@SECLEVEL=0")
|
||||
s = ctx.wrap_socket(socket.create_connection((host, port), timeout=8),
|
||||
server_hostname="fixture")
|
||||
req = "%s %s HTTP/1.1\r\nHost: %s:%d\r\nAccept: */*\r\n" % (method, PATH, host, port)
|
||||
if body is not None:
|
||||
req += "Content-Length: %d\r\n" % len(body)
|
||||
req += "\r\n"
|
||||
s.sendall(req.encode() + (body or b""))
|
||||
out = b""
|
||||
while True:
|
||||
chunk = s.recv(4096)
|
||||
if not chunk:
|
||||
break
|
||||
out += chunk
|
||||
s.close()
|
||||
return out
|
||||
|
||||
|
||||
def capture(host, port):
|
||||
result = {"path": PATH, "responses": {}}
|
||||
servers = set()
|
||||
for method, body in (("GET", None), ("HEAD", None), ("POST", b"probe=1")):
|
||||
raw = fetch(host, port, method, body)
|
||||
m = SERVER_RE.search(raw)
|
||||
if m:
|
||||
servers.add(m.group(1).decode())
|
||||
masked = DATE_RE.sub(b"Date: <MASKED>\r\n", raw)
|
||||
masked = SERVER_RE.sub(b"Server: <MASKED>\r\n", masked)
|
||||
result["responses"][method] = masked.hex()
|
||||
if len(servers) != 1:
|
||||
raise SystemExit("oracle returned inconsistent Server headers: %r" % servers)
|
||||
result["observed_server"] = servers.pop()
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
check = "--check" in sys.argv
|
||||
args = [a for a in sys.argv[1:] if not a.startswith("--")]
|
||||
host, port = (args[0].split(":") if args else ("127.0.0.1", "8081"))[0], \
|
||||
int((args[0].split(":")[1] if args and ":" in args[0] else "8081"))
|
||||
|
||||
try:
|
||||
fresh = capture(host, port)
|
||||
except Exception as e:
|
||||
# Explicitly a failure. A --check that silently passes when it could not
|
||||
# reach the oracle is exactly the class of self-confirming tooling this
|
||||
# project has been bitten by repeatedly.
|
||||
raise SystemExit("cannot reach the roster oracle at %s:%d (%s). "
|
||||
"Refusing to report success." % (host, port, e))
|
||||
|
||||
if check:
|
||||
if not os.path.exists(OUT):
|
||||
raise SystemExit("no fixture at %s -- run without --check first" % OUT)
|
||||
with open(OUT) as f:
|
||||
stored = json.load(f)
|
||||
if stored.get("responses") != fresh["responses"]:
|
||||
for m in sorted(set(stored.get("responses", {})) | set(fresh["responses"])):
|
||||
a = stored.get("responses", {}).get(m)
|
||||
b = fresh["responses"].get(m)
|
||||
if a != b:
|
||||
print("MISMATCH %s\n stored: %s\n live : %s" % (m, a, b))
|
||||
raise SystemExit("roster fixtures differ from the live oracle")
|
||||
if stored.get("observed_server") != fresh["observed_server"]:
|
||||
raise SystemExit(
|
||||
"the oracle's Server header changed: %r -> %r.\n"
|
||||
"Update roster::ORACLE_SERVER and regenerate."
|
||||
% (stored.get("observed_server"), fresh["observed_server"]))
|
||||
print("roster fixtures match the live oracle (%d responses, server=%r)"
|
||||
% (len(fresh["responses"]), fresh["observed_server"]))
|
||||
return
|
||||
|
||||
with open(OUT, "w") as f:
|
||||
json.dump(fresh, f, indent=2, sort_keys=True)
|
||||
f.write("\n")
|
||||
print("wrote %s (%d responses, server=%r)"
|
||||
% (OUT, len(fresh["responses"]), fresh["observed_server"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"127.0.0.1": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331350d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3132372e302e302e313c2f686f73746e616d653e0a0909093c69703e323133303730363433333c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a",
|
||||
"192.0.2.1": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331350d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3139322e302e322e313c2f686f73746e616d653e0a0909093c69703e333232313232353938353c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a",
|
||||
"198.51.100.7": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331380d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3139382e35312e3130302e373c2f686f73746e616d653e0a0909093c69703e333332353235363731313c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a",
|
||||
"203.0.113.42": "485454502f312e3120323030204f4b0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a203331380d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d225554462d38223f3e0a3c736572766572696e7374616e6365696e666f3e0a093c61646472657373206d656d6265723d2230223e0a09093c76616c753e0a0909093c686f73746e616d653e3230332e302e3131332e34323c2f686f73746e616d653e0a0909093c69703e333430353830333831383c2f69703e0a0909093c706f72743e34323133303c2f706f72743e0a09093c2f76616c753e0a093c2f616464726573733e0a093c7365637572653e303c2f7365637572653e0a093c747269616c736572766963656e616d653e3c2f747269616c736572766963656e616d653e0a093c64656661756c74646e73616464726573733e303c2f64656661756c74646e73616464726573733e0a3c2f736572766572696e7374616e6365696e666f3e0a"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"observed_server": "BaseHTTP/0.6 Python/3.12.13",
|
||||
"path": "/fifa17/fut/rosterupdate.xml",
|
||||
"responses": {
|
||||
"GET": "485454502f312e3020323030204f4b0d0a5365727665723a203c4d41534b45443e0d0a446174653a203c4d41534b45443e0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a2036370d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a3c3f786d6c2076657273696f6e3d22312e302220656e636f64696e673d227574662d38223f3e0a3c726f737465727570646174652076657273696f6e3d2230222f3e0a",
|
||||
"HEAD": "485454502f312e3020323030204f4b0d0a5365727665723a203c4d41534b45443e0d0a446174653a203c4d41534b45443e0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a2036370d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a",
|
||||
"POST": "485454502f312e3020323030204f4b0d0a5365727665723a203c4d41534b45443e0d0a446174653a203c4d41534b45443e0d0a436f6e74656e742d547970653a206170706c69636174696f6e2f786d6c0d0a436f6e74656e742d4c656e6774683a2036370d0a436f6e6e656374696f6e3a20636c6f73650d0a0d0a"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,14 @@
|
||||
MARKER at-player-search 19:03:01 UTC
|
||||
watermark=13
|
||||
MARKER NO_FILTER 19:04:35 UTC
|
||||
MARKER GOLD 19:16:44 UTC
|
||||
NOTE: the FIFA 17 My Squad UI labels this filter SPECIAL, not Rare. Captured under label SPECIAL.
|
||||
MARKER SPECIAL 19:21:05 UTC
|
||||
MARKER POSITION_ST 19:22:00 UTC
|
||||
MARKER NATION_ARGENTINA 19:22:53 UTC
|
||||
MARKER LEAGUE_PREMIER 19:23:34 UTC
|
||||
MARKER CLUB_CHELSEA 19:24:34 UTC
|
||||
MARKER GOLD_PLUS_ST 19:25:26 UTC
|
||||
MARKER LEAGUE_PLUS_ST 19:27:27 UTC
|
||||
MARKER PAGINATION 19:28:49 UTC
|
||||
NOTE: no sort control exists in the My Squad UI; sort=desc is a client constant.
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"routes": {
|
||||
"accountInfo": {
|
||||
"body_len": 2,
|
||||
"fields": {
|
||||
"keys": []
|
||||
},
|
||||
"sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
|
||||
"status": 200
|
||||
},
|
||||
"activeSquad": {
|
||||
"body_len": 8034,
|
||||
"fields": {
|
||||
"formation": "f442",
|
||||
"id": 0,
|
||||
"players.count": 23,
|
||||
"slots": {
|
||||
"0": 100000003,
|
||||
"1": 100000006,
|
||||
"10": 100000010,
|
||||
"11": 0,
|
||||
"12": 0,
|
||||
"13": 0,
|
||||
"14": 0,
|
||||
"15": 0,
|
||||
"16": 0,
|
||||
"17": 0,
|
||||
"18": 0,
|
||||
"19": 0,
|
||||
"2": 100000005,
|
||||
"20": 0,
|
||||
"21": 0,
|
||||
"22": 0,
|
||||
"3": 100000008,
|
||||
"4": 100000007,
|
||||
"5": 100000002,
|
||||
"6": 100000004,
|
||||
"7": 100000009,
|
||||
"8": 100000001,
|
||||
"9": 100000025
|
||||
}
|
||||
},
|
||||
"sha256": "07e330ed358fbefe31379cd2462aaac4bdc9c85ee28b7500dd2d963e5ea565bd",
|
||||
"status": 200
|
||||
},
|
||||
"credits": {
|
||||
"body_len": 148,
|
||||
"fields": {
|
||||
"credits": 28112944
|
||||
},
|
||||
"sha256": "0a5f3bac80c3a8ee6f088ccf180f5fdcbcbe8a2ef19c7026e4f21876016d7786",
|
||||
"status": 200
|
||||
},
|
||||
"tradePile": {
|
||||
"body_len": 862,
|
||||
"fields": {
|
||||
"auctionInfo.count": 1
|
||||
},
|
||||
"sha256": "b42bab98202209bd8309beb0eca73dba471688e69fef3e014b59901fbc21fe04",
|
||||
"status": 200
|
||||
},
|
||||
"unassigned": {
|
||||
"body_len": 16,
|
||||
"fields": {
|
||||
"itemData.count": 0
|
||||
},
|
||||
"sha256": "873f8bba8baf9c573fc51b54d100c357b3bf0caeb2ccd104245c295e073cf342",
|
||||
"status": 200
|
||||
},
|
||||
"userMassInfo": {
|
||||
"body_len": 8929,
|
||||
"fields": {
|
||||
"clubAbbr": "OFC",
|
||||
"clubName": "OpenFUT",
|
||||
"personaId": 33068179,
|
||||
"trophies": 0
|
||||
},
|
||||
"sha256": "a616aca1742263c47ade9409693e66ec13b50114e608d88bb94141ce80237b66",
|
||||
"status": 200
|
||||
}
|
||||
},
|
||||
"unix": 1786476617.899911,
|
||||
"upstream": "127.0.0.1:8099"
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"routes": {
|
||||
"accountInfo": {
|
||||
"body_len": 2,
|
||||
"fields": {
|
||||
"keys": []
|
||||
},
|
||||
"sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
|
||||
"status": 200
|
||||
},
|
||||
"activeSquad": {
|
||||
"body_len": 8034,
|
||||
"fields": {
|
||||
"formation": "f442",
|
||||
"id": 0,
|
||||
"players.count": 23,
|
||||
"slots": {
|
||||
"0": 100000003,
|
||||
"1": 100000006,
|
||||
"10": 100000010,
|
||||
"11": 0,
|
||||
"12": 0,
|
||||
"13": 0,
|
||||
"14": 0,
|
||||
"15": 0,
|
||||
"16": 0,
|
||||
"17": 0,
|
||||
"18": 0,
|
||||
"19": 0,
|
||||
"2": 100000005,
|
||||
"20": 0,
|
||||
"21": 0,
|
||||
"22": 0,
|
||||
"3": 100000008,
|
||||
"4": 100000007,
|
||||
"5": 100000002,
|
||||
"6": 100000004,
|
||||
"7": 100000009,
|
||||
"8": 100000001,
|
||||
"9": 100000025
|
||||
}
|
||||
},
|
||||
"sha256": "07e330ed358fbefe31379cd2462aaac4bdc9c85ee28b7500dd2d963e5ea565bd",
|
||||
"status": 200
|
||||
},
|
||||
"credits": {
|
||||
"body_len": 148,
|
||||
"fields": {
|
||||
"credits": 28112944
|
||||
},
|
||||
"sha256": "0a5f3bac80c3a8ee6f088ccf180f5fdcbcbe8a2ef19c7026e4f21876016d7786",
|
||||
"status": 200
|
||||
},
|
||||
"tradePile": {
|
||||
"body_len": 862,
|
||||
"fields": {
|
||||
"auctionInfo.count": 1
|
||||
},
|
||||
"sha256": "b42bab98202209bd8309beb0eca73dba471688e69fef3e014b59901fbc21fe04",
|
||||
"status": 200
|
||||
},
|
||||
"unassigned": {
|
||||
"body_len": 16,
|
||||
"fields": {
|
||||
"itemData.count": 0
|
||||
},
|
||||
"sha256": "873f8bba8baf9c573fc51b54d100c357b3bf0caeb2ccd104245c295e073cf342",
|
||||
"status": 200
|
||||
},
|
||||
"userMassInfo": {
|
||||
"body_len": 8929,
|
||||
"fields": {
|
||||
"clubAbbr": "OFC",
|
||||
"clubName": "OpenFUT",
|
||||
"personaId": 33068179,
|
||||
"trophies": 0
|
||||
},
|
||||
"sha256": "a616aca1742263c47ade9409693e66ec13b50114e608d88bb94141ce80237b66",
|
||||
"status": 200
|
||||
}
|
||||
},
|
||||
"unix": 1786474768.2925124,
|
||||
"upstream": "127.0.0.1:8099"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"squad": [{"rating": 89, "chemistry": 59, "formation": "f442", "id": 0, "squadName": "OpenFUT", "squadType": "REGULAR_SQUAD"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"id": 0, "custom": "[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,50,50,0,50,40,65,0,65,50,50,1]", "squadName": "OpenFUT", "chemistry": 49, "starRating": 90, "rating": 90, "formation": "f433", "squadType": "REGULAR_SQUAD", "manager": [{"id": 100000427, "dream": false}], "players": [{"index": 0, "itemData": {"id": 100000003, "dream": false}, "kitNumber": 1}, {"index": 1, "itemData": {"id": 100000006, "dream": false}, "kitNumber": 4}, {"index": 2, "itemData": {"id": 100000005, "dream": false}, "kitNumber": 3}, {"index": 3, "itemData": {"id": 100000008, "dream": false}, "kitNumber": 6}, {"index": 4, "itemData": {"id": 100000007, "dream": false}, "kitNumber": 5}, {"index": 5, "itemData": {"id": 100000002, "dream": false}, "kitNumber": 9}, {"index": 6, "itemData": {"id": 100000004, "dream": false}, "kitNumber": 2}, {"index": 7, "itemData": {"id": 100000009, "dream": false}, "kitNumber": 7}, {"index": 8, "itemData": {"id": 100000010, "dream": false}, "kitNumber": 10}, {"index": 9, "itemData": {"id": 100000025, "dream": false}, "kitNumber": 11}, {"index": 10, "itemData": {"id": 100000001, "dream": false}, "kitNumber": 8}, {"index": 11, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 12, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 13, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 14, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 15, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 16, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 17, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 18, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 19, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 20, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 21, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 22, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}], "captain": 100000001, "kicktakers": [{"index": 0, "id": 100000001, "dream": false}, {"index": 1, "id": 100000001, "dream": false}, {"index": 2, "id": 100000001, "dream": false}, {"index": 3, "id": 100000001, "dream": false}, {"index": 4, "id": 100000001, "dream": false}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"id":0,"custom":"[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,50,50,0,50,40,65,0,65,50,50,1]","squadName":"OpenFUT","chemistry":52,"starRating":90,"rating":90,"formation":"f442","squadType":"REGULAR_SQUAD","manager":[{"id":100000427,"dream":false}],"players":[{"index":0,"itemData":{"id":100000003,"dream":false},"kitNumber":1},{"index":1,"itemData":{"id":100000010,"dream":false},"kitNumber":10},{"index":2,"itemData":{"id":100000005,"dream":false},"kitNumber":3},{"index":3,"itemData":{"id":100000008,"dream":false},"kitNumber":6},{"index":4,"itemData":{"id":100000007,"dream":false},"kitNumber":5},{"index":5,"itemData":{"id":100000006,"dream":false},"kitNumber":4},{"index":6,"itemData":{"id":100000004,"dream":false},"kitNumber":2},{"index":7,"itemData":{"id":100000009,"dream":false},"kitNumber":7},{"index":8,"itemData":{"id":100000001,"dream":false},"kitNumber":8},{"index":9,"itemData":{"id":100000002,"dream":false},"kitNumber":9},{"index":10,"itemData":{"id":100000025,"dream":false},"kitNumber":11},{"index":11,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":12,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":13,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":14,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":15,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":16,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":17,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":18,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":19,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":20,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":21,"itemData":{"id":0,"dream":false},"kitNumber":0},{"index":22,"itemData":{"id":0,"dream":false},"kitNumber":0}],"captain":100000001,"kicktakers":[{"index":0,"id":100000001,"dream":false},{"index":1,"id":100000001,"dream":false},{"index":2,"id":100000001,"dream":false},{"index":3,"id":100000001,"dream":false},{"index":4,"id":100000001,"dream":false}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"id": 0, "custom": "[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,50,50,0,50,40,65,0,65,50,50,1]", "squadName": "OpenFUT", "chemistry": 58, "starRating": 90, "rating": 90, "formation": "f442", "squadType": "REGULAR_SQUAD", "manager": [{"id": 100000427, "dream": false}], "players": [{"index": 0, "itemData": {"id": 100000003, "dream": false}, "kitNumber": 1}, {"index": 1, "itemData": {"id": 100000006, "dream": false}, "kitNumber": 4}, {"index": 2, "itemData": {"id": 100000005, "dream": false}, "kitNumber": 3}, {"index": 3, "itemData": {"id": 100000008, "dream": false}, "kitNumber": 6}, {"index": 4, "itemData": {"id": 100000007, "dream": false}, "kitNumber": 5}, {"index": 5, "itemData": {"id": 100000002, "dream": false}, "kitNumber": 9}, {"index": 6, "itemData": {"id": 100000004, "dream": false}, "kitNumber": 2}, {"index": 7, "itemData": {"id": 100000009, "dream": false}, "kitNumber": 7}, {"index": 8, "itemData": {"id": 100000001, "dream": false}, "kitNumber": 8}, {"index": 9, "itemData": {"id": 100000010, "dream": false}, "kitNumber": 10}, {"index": 10, "itemData": {"id": 100000025, "dream": false}, "kitNumber": 11}, {"index": 11, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 12, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 13, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 14, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 15, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 16, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 17, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 18, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 19, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 20, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 21, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}, {"index": 22, "itemData": {"id": 0, "dream": false}, "kitNumber": 0}], "captain": 100000001, "kicktakers": [{"index": 0, "id": 100000001, "dream": false}, {"index": 1, "id": 100000001, "dream": false}, {"index": 2, "id": 100000001, "dream": false}, {"index": 3, "id": 100000001, "dream": false}, {"index": 4, "id": 100000001, "dream": false}]}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"routes": {
|
||||
"accountInfo": {
|
||||
"body_len": 2,
|
||||
"fields": {
|
||||
"keys": []
|
||||
},
|
||||
"sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
|
||||
"status": 200
|
||||
},
|
||||
"activeSquad": {
|
||||
"body_len": 8034,
|
||||
"fields": {
|
||||
"formation": "f442",
|
||||
"id": 0,
|
||||
"players.count": 23,
|
||||
"slots": {
|
||||
"0": 100000003,
|
||||
"1": 100000006,
|
||||
"10": 100000010,
|
||||
"11": 0,
|
||||
"12": 0,
|
||||
"13": 0,
|
||||
"14": 0,
|
||||
"15": 0,
|
||||
"16": 0,
|
||||
"17": 0,
|
||||
"18": 0,
|
||||
"19": 0,
|
||||
"2": 100000005,
|
||||
"20": 0,
|
||||
"21": 0,
|
||||
"22": 0,
|
||||
"3": 100000008,
|
||||
"4": 100000007,
|
||||
"5": 100000002,
|
||||
"6": 100000004,
|
||||
"7": 100000009,
|
||||
"8": 100000001,
|
||||
"9": 100000025
|
||||
}
|
||||
},
|
||||
"sha256": "07e330ed358fbefe31379cd2462aaac4bdc9c85ee28b7500dd2d963e5ea565bd",
|
||||
"status": 200
|
||||
},
|
||||
"credits": {
|
||||
"body_len": 148,
|
||||
"fields": {
|
||||
"credits": 28112944
|
||||
},
|
||||
"sha256": "0a5f3bac80c3a8ee6f088ccf180f5fdcbcbe8a2ef19c7026e4f21876016d7786",
|
||||
"status": 200
|
||||
},
|
||||
"tradePile": {
|
||||
"body_len": 862,
|
||||
"fields": {
|
||||
"auctionInfo.count": 1
|
||||
},
|
||||
"sha256": "b42bab98202209bd8309beb0eca73dba471688e69fef3e014b59901fbc21fe04",
|
||||
"status": 200
|
||||
},
|
||||
"unassigned": {
|
||||
"body_len": 16,
|
||||
"fields": {
|
||||
"itemData.count": 0
|
||||
},
|
||||
"sha256": "873f8bba8baf9c573fc51b54d100c357b3bf0caeb2ccd104245c295e073cf342",
|
||||
"status": 200
|
||||
},
|
||||
"userMassInfo": {
|
||||
"body_len": 8929,
|
||||
"fields": {
|
||||
"clubAbbr": "OFC",
|
||||
"clubName": "OpenFUT",
|
||||
"personaId": 33068179,
|
||||
"trophies": 0
|
||||
},
|
||||
"sha256": "a616aca1742263c47ade9409693e66ec13b50114e608d88bb94141ce80237b66",
|
||||
"status": 200
|
||||
}
|
||||
},
|
||||
"unix": 1786472465.339646,
|
||||
"upstream": "127.0.0.1:8099"
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"routes": {
|
||||
"accountInfo": {
|
||||
"body_len": 2,
|
||||
"fields": {
|
||||
"keys": []
|
||||
},
|
||||
"sha256": "44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a",
|
||||
"status": 200
|
||||
},
|
||||
"activeSquad": {
|
||||
"body_len": 8034,
|
||||
"fields": {
|
||||
"formation": "f442",
|
||||
"id": 0,
|
||||
"players.count": 23,
|
||||
"slots": {
|
||||
"0": 100000003,
|
||||
"1": 100000010,
|
||||
"10": 100000001,
|
||||
"11": 100000002,
|
||||
"12": 0,
|
||||
"13": 0,
|
||||
"14": 0,
|
||||
"15": 0,
|
||||
"16": 0,
|
||||
"17": 0,
|
||||
"18": 0,
|
||||
"19": 0,
|
||||
"2": 100000009,
|
||||
"20": 0,
|
||||
"21": 0,
|
||||
"22": 0,
|
||||
"3": 100000008,
|
||||
"4": 100000007,
|
||||
"5": 100000006,
|
||||
"6": 100000005,
|
||||
"7": 100000004,
|
||||
"8": 100000025,
|
||||
"9": 0
|
||||
}
|
||||
},
|
||||
"sha256": "768bb0584ad953ac5f088ad029f161d302ee1be3a63826eb186405acaf1b6232",
|
||||
"status": 200
|
||||
},
|
||||
"credits": {
|
||||
"body_len": 148,
|
||||
"fields": {
|
||||
"credits": 28020656
|
||||
},
|
||||
"sha256": "8d39fee51c24ddee9f12d98d7833af6cd3d3399a0e7567ab7062945a1180e30f",
|
||||
"status": 200
|
||||
},
|
||||
"tradePile": {
|
||||
"body_len": 862,
|
||||
"fields": {
|
||||
"auctionInfo.count": 1
|
||||
},
|
||||
"sha256": "1575046afb8ca60c76de64427ee0c70e1d4ca2da032819a60db95d72d272d11d",
|
||||
"status": 200
|
||||
},
|
||||
"unassigned": {
|
||||
"body_len": 16,
|
||||
"fields": {
|
||||
"itemData.count": 0
|
||||
},
|
||||
"sha256": "873f8bba8baf9c573fc51b54d100c357b3bf0caeb2ccd104245c295e073cf342",
|
||||
"status": 200
|
||||
},
|
||||
"userMassInfo": {
|
||||
"body_len": 8929,
|
||||
"fields": {
|
||||
"clubAbbr": "OFC",
|
||||
"clubName": "OpenFUT",
|
||||
"personaId": 33068179,
|
||||
"trophies": 0
|
||||
},
|
||||
"sha256": "3d89d0497661fc62f107081208a14c4fa5753ee4e6482eeda825fe4b622f871f",
|
||||
"status": 200
|
||||
}
|
||||
},
|
||||
"unix": 1786472102.908128,
|
||||
"upstream": "127.0.0.1:8099"
|
||||
}
|
||||
Executable
+108
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env bash
|
||||
# FIFA 17 squad-adapter mutation battery.
|
||||
#
|
||||
# Each mutation injects a specific WRONG behaviour into the committed source,
|
||||
# runs the one test that defends the invariant, and requires that test to FAIL
|
||||
# (non-zero exit) — i.e. the mutant is killed. The source is reverted via
|
||||
# `git checkout` after every mutation, so the tree is left untouched.
|
||||
#
|
||||
# A mutant that SURVIVES (its guard test still passes) means the invariant is
|
||||
# not actually defended; the battery then exits non-zero.
|
||||
#
|
||||
# Run from the adapter crate root: bash mutation-battery.sh
|
||||
set -u
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
FUT=src/fut
|
||||
PASS=0
|
||||
FAIL=0
|
||||
declare -a SURVIVORS=()
|
||||
|
||||
# mutate <file> <literal-old> <literal-new> (literal, multiline-safe)
|
||||
mutate() {
|
||||
OLD="$2" NEW="$3" perl -0777 -pi -e \
|
||||
's/\Q$ENV{OLD}\E/$ENV{NEW}/g or die "MUTATION PATTERN NOT FOUND in '"$1"'\n"' "$1"
|
||||
}
|
||||
|
||||
# kill <n> <label> <test-filter> <file> <old> <new>
|
||||
kill_test() {
|
||||
local n="$1" label="$2" filter="$3" file="$4" old="$5" new="$6"
|
||||
git checkout -- "$file"
|
||||
mutate "$file" "$old" "$new" || { echo " [#$n] SETUP ERROR"; FAIL=$((FAIL+1)); SURVIVORS+=("#$n $label (setup)"); return; }
|
||||
if cargo test --quiet "$filter" >/dev/null 2>&1; then
|
||||
echo " [#$n] SURVIVED — $label (test '$filter' still passed)"
|
||||
FAIL=$((FAIL+1)); SURVIVORS+=("#$n $label")
|
||||
else
|
||||
echo " [#$n] killed — $label"
|
||||
PASS=$((PASS+1))
|
||||
fi
|
||||
git checkout -- "$file"
|
||||
}
|
||||
|
||||
echo "== FIFA17 squad adapter mutation battery =="
|
||||
|
||||
kill_test 1 "kitNumber keyed by slot index, not owned item" \
|
||||
kit_number_is_keyed_by_owned_item_not_slot "$FUT/squad_ext.rs" \
|
||||
'(s.owned_card_id.clone(), s.kit_number)' '(s.index.to_string(), s.kit_number)'
|
||||
|
||||
kill_test 2 "captain emitted using resourceId (asset), not wire id" \
|
||||
fresh_projects_full_23_slot_array_with_captain_wire_id "$FUT/squad_projection.rs" \
|
||||
'captain_wire = id.item_id as i64;' 'captain_wire = id.asset_id as i64;'
|
||||
|
||||
kill_test 3 "client chemistry silently reconciled (shadow lost)" \
|
||||
swap_moves_two_players_with_their_kits_and_round_trips "$FUT/squad_projection.rs" \
|
||||
'"chemistry": ext.client_reported.chemistry,' '"chemistry": 0,'
|
||||
|
||||
kill_test 4 "custom[] regenerated instead of round-tripped verbatim" \
|
||||
custom_is_preserved_byte_for_byte "$FUT/squad_ext.rs" \
|
||||
'custom: put.custom.clone(),' 'custom: Some("[]".to_string()),'
|
||||
|
||||
kill_test 5 "index derived (zeroed) instead of round-tripped" \
|
||||
baseline_projects_the_known_squad_round_trip "$FUT/squad.rs" \
|
||||
'index: p.index,' 'index: 0,'
|
||||
|
||||
kill_test 6 "stale extension accepted and projected" \
|
||||
stale_is_never_applied "$FUT/squad_projection.rs" \
|
||||
'SquadExtInput::Stale(_) => return Ok(SquadProjection::Stale),' 'SquadExtInput::Stale(ext) => ext,'
|
||||
|
||||
kill_test 7 "missing extension fabricates default fields" \
|
||||
missing_is_explicit_never_fabricated "$FUT/squad_projection.rs" \
|
||||
'SquadExtInput::Missing => return Ok(SquadProjection::Missing),' \
|
||||
'SquadExtInput::Missing => return Ok(SquadProjection::Projected(json!({"custom":"[]","manager":[]}))),'
|
||||
|
||||
kill_test 8 "shaper fabricates asset id, bypassing real FIFA identity" \
|
||||
shapes_real_identity_and_reverse_entity_ids "$FUT/item.rs" \
|
||||
'let asset = id.asset_id;' 'let asset = 0;'
|
||||
|
||||
kill_test 9 "duplicate definition collapses owned instances (id=asset)" \
|
||||
two_owned_copies_of_one_definition_stay_distinct_on_the_wire "$FUT/item.rs" \
|
||||
'"id": id.item_id,' '"id": id.asset_id,'
|
||||
|
||||
kill_test 10 "PUT treated as a partial slot diff (drops slots)" \
|
||||
full_replacement_carries_every_occupied_slot_no_diff "$FUT/squad.rs" \
|
||||
'if p.item_data.id == 0 {' 'if p.item_data.id == 0 || p.index > 1 {'
|
||||
|
||||
kill_test 11 "FIFA wire item id stored in canonical replacement" \
|
||||
full_replacement_carries_every_occupied_slot_no_diff "$FUT/squad.rs" \
|
||||
'ProposedSlot {
|
||||
owned_card_id,' 'ProposedSlot {
|
||||
owned_card_id: p.item_data.id.to_string(),'
|
||||
|
||||
kill_test 12 "projector rebuilds items independently of shared shaper" \
|
||||
persisted_read_round_trips_via_reconstructed_canonical_and_extension "$FUT/squad_projection.rs" \
|
||||
'"itemData": shape_item(item, id, ent),' '"itemData": json!({"id": id.item_id}),'
|
||||
|
||||
kill_test 13 "extension schema version ignored on read" \
|
||||
unknown_schema_version_is_rejected_not_coerced "$FUT/squad_ext.rs" \
|
||||
'if schema_version != EXT_SCHEMA_VERSION {' 'if false {'
|
||||
|
||||
kill_test 14 "player state keyed by CardDefinitionId not OwnedItemId" \
|
||||
two_owned_copies_of_one_definition_project_as_distinct_players "$FUT/squad_projection.rs" \
|
||||
'.get(&slot.owned_card_id)' '.get(&item.card_id)'
|
||||
|
||||
echo "== mutants killed: $PASS / $((PASS+FAIL)) =="
|
||||
if [ "$FAIL" -ne 0 ]; then
|
||||
printf 'SURVIVORS:\n'; printf ' - %s\n' "${SURVIVORS[@]}"
|
||||
exit 1
|
||||
fi
|
||||
echo "all mutants killed"
|
||||
@@ -0,0 +1,177 @@
|
||||
//! `Util::fetchClientConfig` tables.
|
||||
//!
|
||||
//! Between 227 and 243 key/value rows per CFID, overwhelmingly the same RS4
|
||||
//! base URL repeated across 212 endpoint keys. The client resolves a per-call
|
||||
//! key (`FUT_RS4_URL_<CALL>`) before a per-module one
|
||||
//! (`FUT_RS4_APIURL_<MODULE>`), and any call left unresolved falls back to a
|
||||
//! real (dead) EA host — which is what produced "there has been an error
|
||||
//! connecting to FIFA 17 Ultimate Team" mid-session when only the boot subset
|
||||
//! was served. The table has to be complete, not representative.
|
||||
//!
|
||||
//! # Why this is data and not code
|
||||
//!
|
||||
//! The rows are reverse-engineered *configuration*, not logic. They live in
|
||||
//! `fixtures/client_config.json`, derived mechanically from the Python oracle
|
||||
//! and templated on `{advertise}`, `{bind}`, `{pow_content_host}` and
|
||||
//! `{pow_host}` so the adapter stays deployable anywhere. Hand-transcribing 400
|
||||
//! string literals would add a class of silent typo no reviewer can catch, and
|
||||
//! `openfut-core` already loads its content from `data/` for the same reason.
|
||||
//!
|
||||
//! The generator does not take its own templating on trust: it substitutes real
|
||||
//! addresses back in and diffs against the oracle for every section before
|
||||
//! writing the file.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use super::config::AdapterConfig;
|
||||
|
||||
/// Rows for every known CFID, plus `__default__` for unknown ones.
|
||||
const TABLE_JSON: &str = include_str!("../../fixtures/client_config.json");
|
||||
|
||||
type Table = BTreeMap<String, Vec<(String, String)>>;
|
||||
|
||||
fn table() -> &'static Table {
|
||||
static TABLE: OnceLock<Table> = OnceLock::new();
|
||||
TABLE.get_or_init(|| {
|
||||
serde_json::from_str(TABLE_JSON).expect("bundled client_config.json is valid")
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the rows for a CFID, with addresses substituted in.
|
||||
///
|
||||
/// Unknown CFIDs deliberately still receive the shared FUT/RS4/POW rows: those
|
||||
/// consumers read a merged `_all` store and which section contributes is
|
||||
/// unproven, so a present-but-shared table is safer than an empty one.
|
||||
pub fn rows_for(cfid: &str, cfg: &AdapterConfig) -> Vec<(String, String)> {
|
||||
let t = table();
|
||||
let rows = t
|
||||
.get(cfid)
|
||||
.or_else(|| t.get("__default__"))
|
||||
.expect("client_config.json always carries a __default__ section");
|
||||
|
||||
// URL-level tokens resolve through AdapterConfig so those helpers are the
|
||||
// single place a URL shape is defined. Host-level tokens cover the values
|
||||
// that are not one of the three standard URLs (roster, POW API).
|
||||
let utas_base = cfg.utas_base();
|
||||
let nucleus_base = cfg.nucleus_base();
|
||||
let pow_content_url = cfg.pow_content_url();
|
||||
|
||||
rows.iter()
|
||||
.map(|(k, v)| {
|
||||
let v = if v.contains('{') {
|
||||
v.replace("{utas_base}", &utas_base)
|
||||
.replace("{nucleus_base}", &nucleus_base)
|
||||
.replace("{pow_content_url}", &pow_content_url)
|
||||
.replace("{advertise}", &cfg.endpoints.advertise)
|
||||
.replace("{bind}", &cfg.endpoints.bind)
|
||||
.replace("{pow_content_host}", &cfg.endpoints.pow_content_host)
|
||||
.replace("{pow_host}", &cfg.endpoints.pow_host)
|
||||
} else {
|
||||
v.clone()
|
||||
};
|
||||
debug_assert!(!v.contains('{'), "unsubstituted token left in {k}: {v}");
|
||||
(k.clone(), v)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fingerprint of the bundled config table.
|
||||
///
|
||||
/// The table is generated data, so "which binary is this?" is only half the
|
||||
/// question — "which data does it carry?" is the other half. A running host
|
||||
/// logs this at startup so a live FIFA trace can be tied to an exact table, and
|
||||
/// a rebuild that silently picked up regenerated fixtures is visible.
|
||||
///
|
||||
/// FNV-1a, not a security hash and never used as one.
|
||||
pub fn table_fingerprint() -> u64 {
|
||||
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for byte in TABLE_JSON.as_bytes() {
|
||||
hash ^= *byte as u64;
|
||||
hash = hash.wrapping_mul(0x1000_0000_01b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// Every CFID with its own section. Unknown CFIDs are still valid requests.
|
||||
pub fn known_sections() -> Vec<&'static str> {
|
||||
table()
|
||||
.keys()
|
||||
.filter(|k| k.as_str() != "__default__")
|
||||
.map(String::as_str)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> AdapterConfig {
|
||||
let mut c = AdapterConfig::loopback();
|
||||
c.endpoints.advertise = "198.51.100.7".into();
|
||||
c.endpoints.bind = "0.0.0.0".into();
|
||||
c.endpoints.pow_content_host = "198.51.100.7:8085".into();
|
||||
c.endpoints.pow_host = "198.51.100.7:8094".into();
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_table_parses() {
|
||||
assert!(table().contains_key("__default__"));
|
||||
assert!(table().contains_key("BlazeSDK"));
|
||||
assert!(known_sections().len() >= 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_section_is_the_shared_fut_base() {
|
||||
let rows = rows_for("literally-anything", &cfg());
|
||||
assert_eq!(rows.len(), 227);
|
||||
assert!(rows.iter().any(|(k, _)| k == "FUT_RS4_BASE_URL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addresses_are_substituted_not_baked() {
|
||||
let rows = rows_for("BlazeSDK", &cfg());
|
||||
let base = rows
|
||||
.iter()
|
||||
.find(|(k, _)| k == "FUT_RS4_BASE_URL")
|
||||
.expect("base url present");
|
||||
assert_eq!(base.1, "http://198.51.100.7:8099/");
|
||||
assert!(
|
||||
!rows.iter().any(|(_, v)| v.contains('{')),
|
||||
"a template token survived substitution"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nucleus_follows_bind_reproducing_the_oracle() {
|
||||
let rows = rows_for("BlazeSDK", &cfg());
|
||||
let n = rows.iter().find(|(k, _)| k == "nucleusConnect").unwrap();
|
||||
assert_eq!(n.1, "http://0.0.0.0:42131");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roster_section_carries_the_roster_urls() {
|
||||
let rows = rows_for("OSDK_ROSTER", &cfg());
|
||||
let r = rows.iter().find(|(k, _)| k == "ROSTER_URL").unwrap();
|
||||
assert_eq!(r.1, "https://198.51.100.7:8081/fifa17/roster/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_are_sorted_as_the_wire_requires() {
|
||||
// The oracle sorts; the TDF map encoder does not, so order is ours to keep.
|
||||
let rows = rows_for("BlazeSDK", &cfg());
|
||||
let mut sorted = rows.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(rows, sorted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_known_section_substitutes_cleanly() {
|
||||
for cfid in known_sections() {
|
||||
for (k, v) in rows_for(cfid, &cfg()) {
|
||||
assert!(!v.contains('{'), "{cfid}/{k} kept a token: {v}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! Adapter configuration: identity and endpoints.
|
||||
//!
|
||||
//! Everything deployment-dependent lives here, injected by the caller. No
|
||||
//! address, port or persona is baked into the response builders — the
|
||||
//! client/server split exists precisely because the Python responders used to
|
||||
//! assume loopback, and rebuilding that assumption in Rust would undo it.
|
||||
//!
|
||||
//! Note the deliberate asymmetry between *bind* and *advertise*: an advertised
|
||||
//! URL must carry the address the CLIENT can reach, which on a two-machine
|
||||
//! deployment is not the address the server binds.
|
||||
|
||||
/// The forged account the whole stack agrees on.
|
||||
///
|
||||
/// Identity has to be byte-identical across LSX, Blaze, POW and UTAS or the
|
||||
/// client rejects the session, so this is one struct passed everywhere rather
|
||||
/// than constants per responder.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Identity {
|
||||
pub persona_id: i64,
|
||||
pub persona_name: String,
|
||||
/// blazeId / userId. Must be non-zero or login is refused.
|
||||
pub user_id: i64,
|
||||
/// XREF externalId.
|
||||
pub ext_id: i64,
|
||||
pub email: String,
|
||||
/// Must equal `PreAuthResponse.NASP`.
|
||||
pub namespace: String,
|
||||
/// `Blaze::ClientPlatformType`; 4 = pc.
|
||||
pub client_platform: i64,
|
||||
/// `PersonaStatus::Code`; 2 = ACTIVE.
|
||||
pub persona_status: i64,
|
||||
/// `Blaze::UserSessionType`; 0 = normal user.
|
||||
pub user_session_type: i64,
|
||||
/// Fallback locale as a packed four-char int (`'enUS'`). Overwritten per
|
||||
/// session by the client's own preAuth `LANG`/`LOC`.
|
||||
pub account_locale: i64,
|
||||
/// `AccountInfo.LN`, e.g. `"en_US"`.
|
||||
pub locale: String,
|
||||
/// EA offer id.
|
||||
pub content_id: String,
|
||||
pub entitlement_tag: String,
|
||||
/// Must contain `"FIFA17PCBoxContent"` or `"FIFA16PC"` or FUT drops the
|
||||
/// entitlement and the store comes up empty.
|
||||
pub entitlement_group: String,
|
||||
pub title_id: String,
|
||||
pub client_id: String,
|
||||
pub platform: String,
|
||||
}
|
||||
|
||||
impl Default for Identity {
|
||||
/// The project's fixed synthetic offline identity.
|
||||
///
|
||||
/// A default, not a constant: the launcher can select a different persona,
|
||||
/// and FUT saves are isolated per persona id.
|
||||
fn default() -> Identity {
|
||||
Identity {
|
||||
persona_id: 33_068_179,
|
||||
persona_name: "CAGE".into(),
|
||||
user_id: 33_068_179,
|
||||
ext_id: 33_068_179,
|
||||
email: "cage@openfut.local".into(),
|
||||
namespace: "cem_ea_id".into(),
|
||||
client_platform: 4,
|
||||
persona_status: 2,
|
||||
user_session_type: 0,
|
||||
account_locale: 0x656E_5553, // 'enUS'
|
||||
locale: "en_US".into(),
|
||||
content_id: "1027460".into(),
|
||||
entitlement_tag: "ONLINE_ACCESS".into(),
|
||||
entitlement_group: "FIFA17PCBoxContent".into(),
|
||||
title_id: "309111".into(),
|
||||
client_id: "FIFA17-PC-SERVER-BLAZE".into(),
|
||||
platform: "pc".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the client should be told to go next.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Endpoints {
|
||||
/// Address handed to the CLIENT for every next hop. On a split deployment
|
||||
/// this is the backend's LAN address as the game machine sees it.
|
||||
pub advertise: String,
|
||||
/// Address the server binds. Not interchangeable with `advertise`.
|
||||
pub bind: String,
|
||||
/// `host:port` for POW content.
|
||||
pub pow_content_host: String,
|
||||
/// `host:port` for the POW/EASFC API.
|
||||
pub pow_host: String,
|
||||
/// Blaze port ADVERTISED to the client by the redirector.
|
||||
///
|
||||
/// Our choice, not a protocol constant — the client goes wherever
|
||||
/// `<serverinstanceinfo>` sends it. Configurable so a sidecar can be
|
||||
/// advertised on a different port without a rebuild.
|
||||
pub blaze_port: u16,
|
||||
/// UTAS/RS4 port in generated `FUT_RS4_*` URLs.
|
||||
///
|
||||
/// 8099 is the client's own built-in default (`http://easw.easports.com:8099/`
|
||||
/// in CardsDLL), so it is the sane value — but it is still deployment
|
||||
/// configuration, not a constant we are entitled to bake in.
|
||||
pub utas_port: u16,
|
||||
pub telemetry_port: i64,
|
||||
pub ticker_port: i64,
|
||||
pub qos_port: i64,
|
||||
}
|
||||
|
||||
// NOTE: there is deliberately NO `impl Default for Endpoints`.
|
||||
//
|
||||
// A default would silently supply loopback, and a remote deployment that forgot
|
||||
// to set an address would then advertise `127.0.0.1` to a client on another
|
||||
// machine — failing far from the cause. Choosing loopback has to be an explicit
|
||||
// act, so it is a named constructor.
|
||||
|
||||
impl Endpoints {
|
||||
/// Endpoints for a backend the client reaches at `advertise`.
|
||||
///
|
||||
/// POW hosts DERIVE from the advertised host, matching what the deployed
|
||||
/// Python entrypoint does (`POW_HOST="${POW_HOST:-$ADV:8094}"`). They must
|
||||
/// not fall back to loopback independently: that would leave a remote
|
||||
/// deployment emitting loopback POW URLs while every other URL was correct.
|
||||
pub fn advertising(advertise: impl Into<String>) -> Endpoints {
|
||||
let advertise = advertise.into();
|
||||
Endpoints {
|
||||
pow_content_host: format!("{advertise}:8080"),
|
||||
pow_host: format!("{advertise}:8094"),
|
||||
bind: advertise.clone(),
|
||||
advertise,
|
||||
blaze_port: 42130,
|
||||
utas_port: 8099,
|
||||
telemetry_port: 9988,
|
||||
ticker_port: 8999,
|
||||
qos_port: 17502,
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit local-only / oracle mode: game and backend on one host.
|
||||
///
|
||||
/// Named rather than defaulted so that "everything is loopback" is always a
|
||||
/// decision someone made, and greppable.
|
||||
pub fn loopback() -> Endpoints {
|
||||
Endpoints::advertising("127.0.0.1")
|
||||
}
|
||||
}
|
||||
|
||||
/// Full adapter configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AdapterConfig {
|
||||
pub identity: Identity,
|
||||
pub endpoints: Endpoints,
|
||||
/// `PreAuthResponse.SVER`. Carries a trailing newline in the oracle; kept
|
||||
/// because it is on the wire, not because it is meaningful.
|
||||
pub server_version: String,
|
||||
}
|
||||
|
||||
/// `PreAuthResponse.SVER`. On the wire, so it is config rather than a literal.
|
||||
pub const DEFAULT_SERVER_VERSION: &str = "Blaze 15.1.1.3.0 (OpenFUT)\n";
|
||||
|
||||
// No `Default` here either, for the same reason as `Endpoints`.
|
||||
|
||||
impl AdapterConfig {
|
||||
/// Adapter serving a client that reaches this backend at `advertise`.
|
||||
pub fn advertising(advertise: impl Into<String>) -> AdapterConfig {
|
||||
AdapterConfig {
|
||||
identity: Identity::default(),
|
||||
endpoints: Endpoints::advertising(advertise),
|
||||
server_version: DEFAULT_SERVER_VERSION.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit local-only / oracle mode.
|
||||
pub fn loopback() -> AdapterConfig {
|
||||
AdapterConfig::advertising("127.0.0.1")
|
||||
}
|
||||
|
||||
/// `http://<advertise>:8099/` — the RS4/UTAS base.
|
||||
///
|
||||
/// The trailing slash and the scheme are both mandatory: CardsDLL's
|
||||
/// `ServerSettings::resolve` uses the value verbatim once it contains
|
||||
/// `"://"`, and the auth path breaks without the slash.
|
||||
pub fn utas_base(&self) -> String {
|
||||
format!(
|
||||
"http://{}:{}/",
|
||||
self.endpoints.advertise, self.endpoints.utas_port
|
||||
)
|
||||
}
|
||||
|
||||
/// `http://<bind>:42131` — the Nucleus OAuth stub.
|
||||
///
|
||||
/// This derives from **bind**, not advertise, faithfully reproducing the
|
||||
/// Python oracle. On the live split deployment that makes it
|
||||
/// `http://0.0.0.0:42131`, which the client cannot dial — see the crate
|
||||
/// README and the vault. Reproduced deliberately: changing it would break
|
||||
/// byte parity with the only configuration ever proven to work, and the
|
||||
/// fix belongs in a separate, live-validated change.
|
||||
pub fn nucleus_base(&self) -> String {
|
||||
format!("http://{}:42131", self.endpoints.bind)
|
||||
}
|
||||
|
||||
pub fn pow_content_url(&self) -> String {
|
||||
format!("http://{}", self.endpoints.pow_content_host)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn utas_base_keeps_scheme_and_trailing_slash() {
|
||||
let mut cfg = AdapterConfig::loopback();
|
||||
cfg.endpoints.advertise = "10.0.0.5".into();
|
||||
assert_eq!(cfg.utas_base(), "http://10.0.0.5:8099/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nucleus_follows_bind_not_advertise() {
|
||||
// Documents the oracle's behaviour, including its consequence.
|
||||
let mut cfg = AdapterConfig::loopback();
|
||||
cfg.endpoints.advertise = "10.0.0.5".into();
|
||||
cfg.endpoints.bind = "0.0.0.0".into();
|
||||
assert_eq!(cfg.nucleus_base(), "http://0.0.0.0:42131");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pow_content_url_has_no_trailing_slash() {
|
||||
let mut cfg = AdapterConfig::loopback();
|
||||
cfg.endpoints.pow_content_host = "10.0.0.5:8085".into();
|
||||
assert_eq!(cfg.pow_content_url(), "http://10.0.0.5:8085");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
//! Blaze RPC dispatch: inbound frame → outbound frames.
|
||||
//!
|
||||
//! Three behaviours here are load-bearing and none of them are obvious from the
|
||||
//! individual response shapes:
|
||||
//!
|
||||
//! * **Login answers with four frames, in order**: the reply first, then
|
||||
//! `UserAuthenticated`, `UserSessionExtendedDataUpdate`, `UserAdded`.
|
||||
//! * **An unimplemented RPC still gets an empty reply.** Silence makes the
|
||||
//! client wait for a timeout; an empty reply lets every field fall back to a
|
||||
//! client-side default and the boot continues.
|
||||
//! * **Non-request message types get nothing at all** — answering a reply or a
|
||||
//! notification would desynchronise the client's own correlation.
|
||||
//!
|
||||
//! No error replies are emitted. `msgType` 3 exists, but the error-code
|
||||
//! placement is UNRESOLVED — three clean-room sources disagree between
|
||||
//! `header[14:16]`, a metadata `ERRC`, and a payload `CNTX`/`ERRC` — so
|
||||
//! emitting one would be a guess on the wire. Do not add one without a capture.
|
||||
|
||||
use openfut_protocol_blaze::fire2::{Frame, Header, MsgType};
|
||||
use openfut_protocol_blaze::heat2::{self, Struct, Value};
|
||||
|
||||
use super::config::AdapterConfig;
|
||||
use super::ids::{association_lists, auth, census_data, component, user_sessions, util};
|
||||
use super::responses as r;
|
||||
use super::session::Session;
|
||||
|
||||
/// Everything needed to answer one RPC.
|
||||
pub struct Adapter {
|
||||
pub config: AdapterConfig,
|
||||
}
|
||||
|
||||
impl Adapter {
|
||||
pub fn new(config: AdapterConfig) -> Adapter {
|
||||
Adapter { config }
|
||||
}
|
||||
|
||||
/// Answer one inbound frame.
|
||||
///
|
||||
/// `now` is passed in rather than read from the clock so responses are
|
||||
/// reproducible: several bodies stamp a timestamp, and a hidden clock read
|
||||
/// would make every fixture unrepeatable.
|
||||
pub fn dispatch(
|
||||
&self,
|
||||
header: &Header,
|
||||
body: &Struct,
|
||||
session: &mut Session,
|
||||
now: i64,
|
||||
) -> Vec<Frame> {
|
||||
// Transport-level ping, whatever the component/command.
|
||||
if header.msg_type == MsgType::Ping {
|
||||
return vec![reply(header, Vec::new(), MsgType::PingReply)];
|
||||
}
|
||||
// Only requests are answered.
|
||||
if header.msg_type != MsgType::Message {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let cfg = &self.config;
|
||||
match (header.component, header.command) {
|
||||
// ------------------------------------------------------- Util
|
||||
(component::UTIL, util::PRE_AUTH) => {
|
||||
// preAuth is where the session learns who it is talking to:
|
||||
// the service name is echoed back, and the locale is captured
|
||||
// for every later ALOC field.
|
||||
session.service_name = service_name_of(body);
|
||||
if let Some(loc) =
|
||||
find_nested_int(body, "LANG").or_else(|| find_nested_int(body, "LOC"))
|
||||
{
|
||||
session.account_locale = loc;
|
||||
}
|
||||
let svc = session.service_name.clone();
|
||||
reply_tdf(header, &r::preauth_response(&svc, cfg))
|
||||
}
|
||||
|
||||
(component::UTIL, util::PING) => reply_tdf(header, &r::ping_response(now)),
|
||||
|
||||
(component::UTIL, util::FETCH_CLIENT_CONFIG) => {
|
||||
let cfid = get_str(body, "CFID");
|
||||
reply_tdf(header, &r::fetch_config_response(&cfid, cfg))
|
||||
}
|
||||
|
||||
(component::UTIL, util::POST_AUTH) => {
|
||||
reply_tdf(header, &r::post_auth_response(session, cfg))
|
||||
}
|
||||
|
||||
(component::UTIL, util::FETCH_QOS_CONFIG) => reply_tdf(header, &r::qos_config(cfg)),
|
||||
|
||||
(component::UTIL, util::USER_SETTINGS_LOAD) => {
|
||||
reply_tdf(header, &r::user_settings_response())
|
||||
}
|
||||
|
||||
// Accepted and discarded; the client only needs the ack.
|
||||
(component::UTIL, util::USER_SETTINGS_SAVE)
|
||||
| (component::UTIL, util::SET_CLIENT_STATE)
|
||||
| (component::UTIL, util::SET_CLIENT_METRICS) => empty_reply(header),
|
||||
|
||||
// --------------------------------------------- Authentication
|
||||
(component::AUTHENTICATION, auth::LOGIN) => {
|
||||
session.auth_code = get_str(body, "AUTH");
|
||||
session.logged_in = true;
|
||||
session.login_time = now;
|
||||
self.login_burst(header, session, now)
|
||||
}
|
||||
|
||||
// Same forged session; the request fields differ and are ignored.
|
||||
(component::AUTHENTICATION, auth::TRUSTED_LOGIN)
|
||||
| (component::AUTHENTICATION, auth::EXPRESS_LOGIN) => {
|
||||
session.logged_in = true;
|
||||
session.login_time = now;
|
||||
self.login_burst(header, session, now)
|
||||
}
|
||||
|
||||
// Receiving logout is NORMAL, not a failure: the OSDK state table
|
||||
// orders Connect -> Logout -> VersionCheck -> PCLogin, so this is
|
||||
// the routine "drop any stale session" step before login. It is
|
||||
// only a symptom if login never follows.
|
||||
(component::AUTHENTICATION, auth::LOGOUT) => empty_reply(header),
|
||||
|
||||
(component::AUTHENTICATION, auth::LIST_USER_ENTITLEMENTS2)
|
||||
| (component::AUTHENTICATION, auth::LIST_ENTITLEMENTS)
|
||||
| (component::AUTHENTICATION, auth::LIST_PERSONA_ENTITLEMENTS2)
|
||||
| (component::AUTHENTICATION, auth::GRANT_ENTITLEMENT2) => {
|
||||
reply_tdf(header, &r::entitlements_response(cfg))
|
||||
}
|
||||
|
||||
(component::AUTHENTICATION, auth::GET_AUTH_TOKEN) => {
|
||||
reply_tdf(header, &r::get_auth_token_response(session))
|
||||
}
|
||||
(component::AUTHENTICATION, auth::GET_ACCOUNT) => {
|
||||
reply_tdf(header, &r::account_info(now, cfg))
|
||||
}
|
||||
(component::AUTHENTICATION, auth::GET_PERSONA) => {
|
||||
reply_tdf(header, &r::get_persona_response(now, cfg))
|
||||
}
|
||||
(component::AUTHENTICATION, auth::LIST_PERSONAS) => {
|
||||
reply_tdf(header, &r::list_personas_response(now, cfg))
|
||||
}
|
||||
|
||||
// ---------------------------------------------- UserSessions
|
||||
(component::USER_SESSIONS, user_sessions::UPDATE_NETWORK_INFO) => {
|
||||
// Ack, then re-push the extended data so the client's cached
|
||||
// copy reflects the network info it just reported.
|
||||
vec![
|
||||
reply(header, Vec::new(), MsgType::Reply),
|
||||
notify(
|
||||
component::USER_SESSIONS,
|
||||
user_sessions::notify::EXTENDED_DATA_UPDATE,
|
||||
&r::user_session_extended_data_update(cfg),
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
// ------------------------------------------ AssociationLists
|
||||
(component::ASSOCIATION_LISTS, association_lists::GET_LISTS) => {
|
||||
reply_tdf(header, &r::get_lists_response())
|
||||
}
|
||||
|
||||
// ----------------------------------------------- CensusData
|
||||
(component::CENSUS_DATA, census_data::SUBSCRIBE_TO_CENSUS_DATA_UPDATES) => {
|
||||
reply_tdf(header, &r::census_subscribe_response())
|
||||
}
|
||||
|
||||
// An empty reply, never silence: see the module docs.
|
||||
_ => empty_reply(header),
|
||||
}
|
||||
}
|
||||
|
||||
/// Login reply followed by the three UserSessions pushes, in order.
|
||||
///
|
||||
/// The order is the oracle's ("pamplona" order: reply first). The
|
||||
/// alternative ("grid-blaze": notifications first) is also reported to
|
||||
/// work, but only this one is proven against our client, so it is the one
|
||||
/// reproduced.
|
||||
fn login_burst(&self, header: &Header, session: &Session, now: i64) -> Vec<Frame> {
|
||||
let cfg = &self.config;
|
||||
vec![
|
||||
reply(
|
||||
header,
|
||||
heat2::encode(&r::login_response(session, now, cfg)),
|
||||
MsgType::Reply,
|
||||
),
|
||||
notify(
|
||||
component::USER_SESSIONS,
|
||||
user_sessions::notify::USER_AUTHENTICATED,
|
||||
&r::user_session_login_info(session, now, cfg),
|
||||
),
|
||||
notify(
|
||||
component::USER_SESSIONS,
|
||||
user_sessions::notify::EXTENDED_DATA_UPDATE,
|
||||
&r::user_session_extended_data_update(cfg),
|
||||
),
|
||||
notify(
|
||||
component::USER_SESSIONS,
|
||||
user_sessions::notify::USER_ADDED,
|
||||
&r::user_data(session, cfg),
|
||||
),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ helpers
|
||||
|
||||
fn reply(request: &Header, payload: Vec<u8>, msg_type: MsgType) -> Frame {
|
||||
let mut frame = Frame::new(
|
||||
request.component,
|
||||
request.command,
|
||||
request.msg_num,
|
||||
msg_type,
|
||||
payload,
|
||||
);
|
||||
// A reply echoes routing verbatim and changes only the msgType bits.
|
||||
frame.header.user_index = request.user_index;
|
||||
frame
|
||||
}
|
||||
|
||||
fn reply_tdf(request: &Header, body: &Struct) -> Vec<Frame> {
|
||||
vec![reply(request, heat2::encode(body), MsgType::Reply)]
|
||||
}
|
||||
|
||||
fn empty_reply(request: &Header) -> Vec<Frame> {
|
||||
vec![reply(request, Vec::new(), MsgType::Reply)]
|
||||
}
|
||||
|
||||
fn notify(component: u16, notify_id: u16, body: &Struct) -> Frame {
|
||||
Frame::notification(component, notify_id, heat2::encode(body))
|
||||
}
|
||||
|
||||
/// `PreAuthRequest.CDAT.SVCN`, echoed back as `INST`.
|
||||
fn service_name_of(body: &Struct) -> String {
|
||||
body.get("CDAT")
|
||||
.and_then(Value::as_struct)
|
||||
.and_then(|c| c.get("SVCN"))
|
||||
.and_then(Value::as_str)
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or(super::session::DEFAULT_SERVICE_NAME)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Depth-first search for an INT member anywhere in a decoded body.
|
||||
///
|
||||
/// The client has moved which struct carries `LANG`/`LOC` between builds, so
|
||||
/// the oracle searches rather than addressing a fixed path.
|
||||
fn find_nested_int(body: &Struct, tag: &str) -> Option<i64> {
|
||||
for (t, v) in body.iter() {
|
||||
if t.to_label() == tag {
|
||||
if let Value::Int(n) = v {
|
||||
return Some(*n);
|
||||
}
|
||||
}
|
||||
if let Value::Struct(inner) = v {
|
||||
if let Some(found) = find_nested_int(inner, tag) {
|
||||
return Some(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn get_str(body: &Struct, tag: &str) -> String {
|
||||
body.get(tag)
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use openfut_protocol_blaze::heat2::Struct as S;
|
||||
|
||||
fn adapter() -> Adapter {
|
||||
Adapter::new(AdapterConfig::loopback())
|
||||
}
|
||||
|
||||
fn req(component: u16, command: u16) -> Header {
|
||||
Header::new(component, command, 7, MsgType::Message)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_answers_with_reply_then_three_pushes_in_order() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let out = a.dispatch(
|
||||
&req(component::AUTHENTICATION, auth::LOGIN),
|
||||
&S::new(),
|
||||
&mut sess,
|
||||
1,
|
||||
);
|
||||
|
||||
assert_eq!(out.len(), 4);
|
||||
assert_eq!(out[0].header.msg_type, MsgType::Reply);
|
||||
let ids: Vec<u16> = out[1..].iter().map(|f| f.header.command).collect();
|
||||
assert_eq!(
|
||||
ids,
|
||||
vec![
|
||||
user_sessions::notify::USER_AUTHENTICATED,
|
||||
user_sessions::notify::EXTENDED_DATA_UPDATE,
|
||||
user_sessions::notify::USER_ADDED,
|
||||
]
|
||||
);
|
||||
for f in &out[1..] {
|
||||
assert_eq!(f.header.msg_type, MsgType::Notification);
|
||||
assert_eq!(f.header.msg_num, 0, "notifications are uncorrelated");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unimplemented_rpcs_get_an_empty_reply_not_silence() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let out = a.dispatch(&req(0x1234, 0x0001), &S::new(), &mut sess, 1);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].header.msg_type, MsgType::Reply);
|
||||
assert!(out[0].payload.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_requests_are_ignored_entirely() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
for mt in [
|
||||
MsgType::Reply,
|
||||
MsgType::Notification,
|
||||
MsgType::ErrorReply,
|
||||
MsgType::PingReply,
|
||||
] {
|
||||
let h = Header::new(component::UTIL, util::PING, 1, mt);
|
||||
assert!(a.dispatch(&h, &S::new(), &mut sess, 1).is_empty(), "{mt:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transport_ping_gets_an_empty_ping_reply() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let h = Header::new(component::UTIL, util::PING, 1, MsgType::Ping);
|
||||
let out = a.dispatch(&h, &S::new(), &mut sess, 1);
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].header.msg_type, MsgType::PingReply);
|
||||
assert!(out[0].payload.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replies_echo_routing_including_user_index() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let mut h = req(component::UTIL, util::PING);
|
||||
h.user_index = 7;
|
||||
h.msg_num = 0x4242;
|
||||
let out = a.dispatch(&h, &S::new(), &mut sess, 1);
|
||||
assert_eq!(out[0].header.user_index, 7);
|
||||
assert_eq!(out[0].header.msg_num, 0x4242);
|
||||
assert_eq!(out[0].header.component, component::UTIL);
|
||||
assert_eq!(out[0].header.command, util::PING);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preauth_captures_locale_and_service_name() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0x656E5553);
|
||||
let body = S::new().with(
|
||||
"CDAT",
|
||||
Value::Struct(
|
||||
S::new()
|
||||
.with("LANG", Value::Int(0x64654445))
|
||||
.with("SVCN", Value::String("fifa-2017-pc-de".into())),
|
||||
),
|
||||
);
|
||||
a.dispatch(&req(component::UTIL, util::PRE_AUTH), &body, &mut sess, 1);
|
||||
assert_eq!(sess.account_locale, 0x64654445);
|
||||
assert_eq!(sess.service_name, "fifa-2017-pc-de");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preauth_without_svcn_falls_back_to_the_default() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
a.dispatch(
|
||||
&req(component::UTIL, util::PRE_AUTH),
|
||||
&S::new(),
|
||||
&mut sess,
|
||||
1,
|
||||
);
|
||||
assert_eq!(
|
||||
sess.service_name,
|
||||
super::super::session::DEFAULT_SERVICE_NAME
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_records_the_auth_code_for_later_get_auth_token() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let body = S::new().with("AUTH", Value::String("CODE-123".into()));
|
||||
a.dispatch(
|
||||
&req(component::AUTHENTICATION, auth::LOGIN),
|
||||
&body,
|
||||
&mut sess,
|
||||
1,
|
||||
);
|
||||
|
||||
let out = a.dispatch(
|
||||
&req(component::AUTHENTICATION, auth::GET_AUTH_TOKEN),
|
||||
&S::new(),
|
||||
&mut sess,
|
||||
1,
|
||||
);
|
||||
let decoded = heat2::decode(&out[0].payload).unwrap();
|
||||
assert_eq!(
|
||||
decoded.get("AUTH").and_then(Value::as_str),
|
||||
Some("CODE-123")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_network_info_acks_then_pushes() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let out = a.dispatch(
|
||||
&req(component::USER_SESSIONS, user_sessions::UPDATE_NETWORK_INFO),
|
||||
&S::new(),
|
||||
&mut sess,
|
||||
1,
|
||||
);
|
||||
assert_eq!(out.len(), 2);
|
||||
assert!(out[0].payload.is_empty());
|
||||
assert_eq!(out[1].header.msg_type, MsgType::Notification);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_entitlement_aliases_agree() {
|
||||
let a = adapter();
|
||||
let mut sess = Session::new("k", 0);
|
||||
let bodies: Vec<Vec<u8>> = [
|
||||
auth::LIST_USER_ENTITLEMENTS2,
|
||||
auth::LIST_ENTITLEMENTS,
|
||||
auth::LIST_PERSONA_ENTITLEMENTS2,
|
||||
auth::GRANT_ENTITLEMENT2,
|
||||
]
|
||||
.iter()
|
||||
.map(|&cmd| {
|
||||
a.dispatch(
|
||||
&req(component::AUTHENTICATION, cmd),
|
||||
&S::new(),
|
||||
&mut sess,
|
||||
1,
|
||||
)[0]
|
||||
.payload
|
||||
.clone()
|
||||
})
|
||||
.collect();
|
||||
assert!(bodies.windows(2).all(|w| w[0] == w[1]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_a_nested_int_at_any_depth() {
|
||||
let body = S::new().with(
|
||||
"A",
|
||||
Value::Struct(S::new().with("B", Value::Struct(S::new().with("LANG", Value::Int(42))))),
|
||||
);
|
||||
assert_eq!(find_nested_int(&body, "LANG"), Some(42));
|
||||
assert_eq!(find_nested_int(&body, "NOPE"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
//! FIFA 17 Blaze component, command and notification IDs.
|
||||
//!
|
||||
//! This is exactly the knowledge that must NOT live in
|
||||
//! `openfut-protocol-blaze`: the generic layer routes on numbers, and what
|
||||
//! those numbers mean is per-title.
|
||||
//!
|
||||
//! # Provenance
|
||||
//!
|
||||
//! The Util table was recovered from FIFA17.exe's own `getCommandName` switch
|
||||
//! (jump table `0x141b17af4`). The Authentication table could not be recovered
|
||||
//! statically — the name pool is Denuvo-mutated — so it was obtained by CALLING
|
||||
//! the client's own `getCommandName` (`0x146e0d2a0`) in-process over ids 1..320,
|
||||
//! validated by reproducing the known Util names, and cross-checked against a
|
||||
//! static REST-binding struct (`0x143896a80` → `trustedLogin = 0x0B`).
|
||||
//! UserSessions notification ids come from the clean, unmutated
|
||||
//! `getNotificationName` jump table at `0x141b03f70`.
|
||||
//!
|
||||
//! Names are for diagnostics only. Dispatch matches on the numeric constants.
|
||||
|
||||
pub mod component {
|
||||
pub const AUTHENTICATION: u16 = 0x0001;
|
||||
pub const GAME_MANAGER: u16 = 0x0004;
|
||||
pub const REDIRECTOR: u16 = 0x0005;
|
||||
pub const STATS: u16 = 0x0007;
|
||||
pub const UTIL: u16 = 0x0009;
|
||||
pub const CENSUS_DATA: u16 = 0x000A;
|
||||
pub const CLUBS: u16 = 0x000B;
|
||||
pub const MESSAGING: u16 = 0x000F;
|
||||
pub const ASSOCIATION_LISTS: u16 = 0x0019;
|
||||
pub const GAME_REPORTING: u16 = 0x001C;
|
||||
pub const SPONSORED_EVENTS: u16 = 0x081C;
|
||||
pub const OSDK_SETTINGS: u16 = 0x08C9;
|
||||
pub const USER_SESSIONS: u16 = 0x7802;
|
||||
}
|
||||
|
||||
pub mod util {
|
||||
pub const FETCH_CLIENT_CONFIG: u16 = 0x0001;
|
||||
pub const PING: u16 = 0x0002;
|
||||
pub const PRE_AUTH: u16 = 0x0007;
|
||||
pub const POST_AUTH: u16 = 0x0008;
|
||||
pub const USER_SETTINGS_LOAD: u16 = 0x000A;
|
||||
pub const USER_SETTINGS_SAVE: u16 = 0x000B;
|
||||
pub const FETCH_QOS_CONFIG: u16 = 0x0015;
|
||||
pub const SET_CLIENT_METRICS: u16 = 0x0016;
|
||||
pub const SET_CLIENT_STATE: u16 = 0x001C;
|
||||
}
|
||||
|
||||
pub mod auth {
|
||||
pub const LOGIN: u16 = 0x000A;
|
||||
pub const TRUSTED_LOGIN: u16 = 0x000B;
|
||||
pub const LIST_USER_ENTITLEMENTS2: u16 = 0x001D;
|
||||
pub const GET_ACCOUNT: u16 = 0x001E;
|
||||
pub const LIST_ENTITLEMENTS: u16 = 0x0020;
|
||||
pub const GET_AUTH_TOKEN: u16 = 0x0024;
|
||||
pub const GRANT_ENTITLEMENT2: u16 = 0x0027;
|
||||
pub const LIST_PERSONA_ENTITLEMENTS2: u16 = 0x0030;
|
||||
pub const EXPRESS_LOGIN: u16 = 0x003C;
|
||||
/// Routine "drop any stale session" step before PCLogin, NOT a failure.
|
||||
pub const LOGOUT: u16 = 0x0046;
|
||||
pub const GET_PERSONA: u16 = 0x005A;
|
||||
pub const LIST_PERSONAS: u16 = 0x0064;
|
||||
}
|
||||
|
||||
pub mod user_sessions {
|
||||
pub const UPDATE_NETWORK_INFO: u16 = 0x0014;
|
||||
|
||||
/// Notification ids live in a separate number space from commands.
|
||||
pub mod notify {
|
||||
pub const EXTENDED_DATA_UPDATE: u16 = 0x0001;
|
||||
pub const USER_ADDED: u16 = 0x0002;
|
||||
pub const USER_REMOVED: u16 = 0x0003;
|
||||
pub const USER_UPDATED: u16 = 0x0005;
|
||||
pub const USER_AUTHENTICATED: u16 = 0x0008;
|
||||
pub const USER_UNAUTHENTICATED: u16 = 0x0009;
|
||||
pub const SERVER_DRAINING: u16 = 0x000C;
|
||||
}
|
||||
}
|
||||
|
||||
pub mod association_lists {
|
||||
pub const GET_LISTS: u16 = 0x0006;
|
||||
}
|
||||
|
||||
pub mod census_data {
|
||||
pub const SUBSCRIBE_TO_CENSUS_DATA_UPDATES: u16 = 0x0005;
|
||||
}
|
||||
|
||||
/// Components advertised in `PreAuthResponse.CIDS`.
|
||||
///
|
||||
/// Order is the oracle's and is preserved: `CIDS` is a TDF list, and list
|
||||
/// elements are NOT reordered by the encoder the way struct members are.
|
||||
pub const ADVERTISED_COMPONENT_IDS: [i64; 9] = [
|
||||
component::AUTHENTICATION as i64,
|
||||
component::GAME_MANAGER as i64,
|
||||
component::REDIRECTOR as i64,
|
||||
component::STATS as i64,
|
||||
component::UTIL as i64,
|
||||
component::MESSAGING as i64,
|
||||
component::ASSOCIATION_LISTS as i64,
|
||||
component::GAME_REPORTING as i64,
|
||||
component::USER_SESSIONS as i64,
|
||||
];
|
||||
|
||||
pub fn component_name(component: u16) -> Option<&'static str> {
|
||||
Some(match component {
|
||||
component::AUTHENTICATION => "Authentication",
|
||||
component::GAME_MANAGER => "GameManager",
|
||||
component::REDIRECTOR => "Redirector",
|
||||
component::STATS => "Stats",
|
||||
component::UTIL => "Util",
|
||||
component::CENSUS_DATA => "CensusData",
|
||||
component::CLUBS => "Clubs",
|
||||
component::MESSAGING => "Messaging",
|
||||
component::ASSOCIATION_LISTS => "AssociationLists",
|
||||
component::GAME_REPORTING => "GameReporting",
|
||||
component::SPONSORED_EVENTS => "SponsoredEvents",
|
||||
component::OSDK_SETTINGS => "OSDKSettings",
|
||||
component::USER_SESSIONS => "UserSessions",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn command_name(component: u16, command: u16) -> Option<&'static str> {
|
||||
Some(match (component, command) {
|
||||
(component::UTIL, 0x01) => "fetchClientConfig",
|
||||
(component::UTIL, 0x02) => "ping",
|
||||
(component::UTIL, 0x03) => "setClientData",
|
||||
(component::UTIL, 0x04) => "localizeStrings",
|
||||
(component::UTIL, 0x05) => "getTelemetryServer",
|
||||
(component::UTIL, 0x06) => "getTickerServer",
|
||||
(component::UTIL, 0x07) => "preAuth",
|
||||
(component::UTIL, 0x08) => "postAuth",
|
||||
(component::UTIL, 0x0A) => "userSettingsLoad",
|
||||
(component::UTIL, 0x0B) => "userSettingsSave",
|
||||
(component::UTIL, 0x0C) => "userSettingsLoadAll",
|
||||
(component::UTIL, 0x0E) => "userSettingsDelete",
|
||||
(component::UTIL, 0x0F) => "userSettingsLoadAllForUser",
|
||||
(component::UTIL, 0x14) => "filterForProfanity",
|
||||
(component::UTIL, 0x15) => "fetchQosConfig",
|
||||
(component::UTIL, 0x16) => "setClientMetrics",
|
||||
(component::UTIL, 0x17) => "setConnectionState",
|
||||
(component::UTIL, 0x19) => "getUserOptions",
|
||||
(component::UTIL, 0x1A) => "setUserOptions",
|
||||
(component::UTIL, 0x1B) => "suspendUserPing",
|
||||
(component::UTIL, 0x1C) => "setClientState",
|
||||
|
||||
(component::AUTHENTICATION, 0x0A) => "login",
|
||||
(component::AUTHENTICATION, 0x0B) => "trustedLogin",
|
||||
(component::AUTHENTICATION, 0x14) => "updateAccount",
|
||||
(component::AUTHENTICATION, 0x15) => "upgradeAccount",
|
||||
(component::AUTHENTICATION, 0x1D) => "listUserEntitlements2",
|
||||
(component::AUTHENTICATION, 0x1E) => "getAccount",
|
||||
(component::AUTHENTICATION, 0x1F) => "grantEntitlement",
|
||||
(component::AUTHENTICATION, 0x20) => "listEntitlements",
|
||||
(component::AUTHENTICATION, 0x22) => "getUseCount",
|
||||
(component::AUTHENTICATION, 0x23) => "decrementUseCount",
|
||||
(component::AUTHENTICATION, 0x24) => "getAuthToken",
|
||||
(component::AUTHENTICATION, 0x26) => "getPasswordRules",
|
||||
(component::AUTHENTICATION, 0x27) => "grantEntitlement2",
|
||||
(component::AUTHENTICATION, 0x2B) => "modifyEntitlement2",
|
||||
(component::AUTHENTICATION, 0x2C) => "consumecode",
|
||||
(component::AUTHENTICATION, 0x2D) => "passwordForgot",
|
||||
(component::AUTHENTICATION, 0x2F) => "getPrivacyPolicyContent",
|
||||
(component::AUTHENTICATION, 0x30) => "listPersonaEntitlements2",
|
||||
(component::AUTHENTICATION, 0x33) => "checkAgeReq",
|
||||
(component::AUTHENTICATION, 0x34) => "getOptIn",
|
||||
(component::AUTHENTICATION, 0x35) => "enableOptIn",
|
||||
(component::AUTHENTICATION, 0x36) => "disableOptIn",
|
||||
(component::AUTHENTICATION, 0x3C) => "expressLogin",
|
||||
(component::AUTHENTICATION, 0x46) => "logout",
|
||||
(component::AUTHENTICATION, 0x5A) => "getPersona",
|
||||
(component::AUTHENTICATION, 0x64) => "listPersonas",
|
||||
(component::AUTHENTICATION, 0x65) => "expressCreateAccount",
|
||||
(component::AUTHENTICATION, 0xE6) => "createWalUserSession",
|
||||
(component::AUTHENTICATION, 0xF1) => "acceptLegalDocs",
|
||||
(component::AUTHENTICATION, 0xF2) => "getEmailOptInSettings",
|
||||
(component::AUTHENTICATION, 0xF6) => "getTermsOfServiceContent",
|
||||
(component::AUTHENTICATION, 0x104) => "getOriginPersona",
|
||||
(component::AUTHENTICATION, 0x10E) => "checkEmail",
|
||||
(component::AUTHENTICATION, 0x118) => "getPersonaNameSuggestions",
|
||||
(component::AUTHENTICATION, 0x122) => "guestLogin",
|
||||
|
||||
(component::CENSUS_DATA, 0x01) => "subscribeToCensusData",
|
||||
(component::CENSUS_DATA, 0x02) => "unsubscribeFromCensusData",
|
||||
(component::CENSUS_DATA, 0x03) => "getRegionCounts",
|
||||
(component::CENSUS_DATA, 0x04) => "getLatestCensusData",
|
||||
(component::CENSUS_DATA, 0x05) => "subscribeToCensusDataUpdates",
|
||||
|
||||
(component::USER_SESSIONS, 0x14) => "updateNetworkInfo",
|
||||
(component::ASSOCIATION_LISTS, 0x06) => "getLists",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn notification_name(component: u16, notify_id: u16) -> Option<&'static str> {
|
||||
if component != component::USER_SESSIONS {
|
||||
return None;
|
||||
}
|
||||
Some(match notify_id {
|
||||
0x01 => "UserSessionExtendedDataUpdate",
|
||||
0x02 => "UserAdded",
|
||||
0x03 => "UserRemoved",
|
||||
0x05 => "UserUpdated",
|
||||
0x08 => "UserAuthenticated",
|
||||
0x09 => "UserUnauthenticated",
|
||||
0x0C => "ServerDraining",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Human-readable label for a route, for logs and captures.
|
||||
pub fn describe(component: u16, command: u16, is_notification: bool) -> String {
|
||||
let comp = component_name(component)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("Component:0x{component:04x}"));
|
||||
if is_notification {
|
||||
if let Some(n) = notification_name(component, command) {
|
||||
return format!("{comp}::<{n}>");
|
||||
}
|
||||
return format!("{comp}::<notify:0x{command:04x}>");
|
||||
}
|
||||
match command_name(component, command) {
|
||||
Some(name) => format!("{comp}::{name}"),
|
||||
None => format!("{comp}::cmd:0x{command:04x}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn describes_the_first_rpc_fifa_sends() {
|
||||
assert_eq!(
|
||||
describe(component::UTIL, util::PRE_AUTH, false),
|
||||
"Util::preAuth"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_and_notifications_are_separate_number_spaces() {
|
||||
// 0x0002 is UserSessions "UserAdded" as a notification, but is not a
|
||||
// known UserSessions *command*.
|
||||
assert_eq!(
|
||||
describe(component::USER_SESSIONS, 0x0002, true),
|
||||
"UserSessions::<UserAdded>"
|
||||
);
|
||||
assert_eq!(
|
||||
describe(component::USER_SESSIONS, 0x0002, false),
|
||||
"UserSessions::cmd:0x0002"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_routes_degrade_to_numbers() {
|
||||
assert_eq!(
|
||||
describe(0x1234, 0x0001, false),
|
||||
"Component:0x1234::cmd:0x0001"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advertised_components_are_in_oracle_order() {
|
||||
// A list, not a struct: the encoder will NOT sort these, so the order
|
||||
// here is the order on the wire.
|
||||
assert_eq!(ADVERTISED_COMPONENT_IDS[0], 0x0001);
|
||||
assert_eq!(ADVERTISED_COMPONENT_IDS[8], 0x7802);
|
||||
assert_eq!(ADVERTISED_COMPONENT_IDS.len(), 9);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! FIFA 17 Blaze adapter.
|
||||
//!
|
||||
//! Sits on `openfut-protocol-blaze` (Fire2 framing + Heat2/TDF) and supplies
|
||||
//! everything the generic layer deliberately refuses to know: which component
|
||||
//! and command numbers mean what, what each response body must contain, and in
|
||||
//! what order frames leave the server.
|
||||
//!
|
||||
//! ```text
|
||||
//! FIFA 17 client
|
||||
//! │ Fire2 frames
|
||||
//! openfut-protocol-blaze generic: framing + codec
|
||||
//! │ Header + Struct
|
||||
//! blaze::Adapter THIS: FIFA 17 ids, bodies, ordering
|
||||
//! │ (future) semantic calls
|
||||
//! OpenFUT Core game-independent FUT domain
|
||||
//! ```
|
||||
//!
|
||||
//! Blaze is an auth/session/config protocol: no coins, packs, clubs or squads
|
||||
//! appear on this wire, so the adapter carries no FUT domain state and has no
|
||||
//! reason to grow into a second backend.
|
||||
|
||||
pub mod client_config;
|
||||
pub mod config;
|
||||
pub mod dispatch;
|
||||
pub mod ids;
|
||||
pub mod responses;
|
||||
pub mod session;
|
||||
|
||||
pub use config::{AdapterConfig, Endpoints, Identity};
|
||||
pub use dispatch::Adapter;
|
||||
pub use session::Session;
|
||||
@@ -0,0 +1,623 @@
|
||||
//! FIFA 17 Blaze response bodies.
|
||||
//!
|
||||
//! Every builder here mirrors a `Blaze::*` TDF class reversed from FIFA17.exe's
|
||||
//! own reflection metadata. Member counts and tags are not guesses, and the
|
||||
//! comments carry the class addresses so a future reader can re-derive them.
|
||||
//!
|
||||
//! Two recurring rules, both learned the hard way:
|
||||
//!
|
||||
//! * **An absent member is safe; a wrongly-typed one is fatal.** A member the
|
||||
//! client does not receive keeps its client-side default. A member encoded
|
||||
//! with the wrong wire type desynchronises the whole TDF parse. That is why
|
||||
//! `CGID`, `ADDR`, `CVAR` and `ULST` are omitted rather than guessed — their
|
||||
//! union/objid encodings are UNVERIFIED.
|
||||
//! * **Identity must be byte-identical across responses.** `MAIL`/`UID`/`ASRC`
|
||||
//! in `AccountInfo` must match `LoginResponse.SESS` and `PreAuthResponse.NASP`,
|
||||
//! and the session key must be the same string in three places.
|
||||
//!
|
||||
//! Member order in the source below is the oracle's for readability; the
|
||||
//! encoder sorts by packed tag, so source order never reaches the wire.
|
||||
|
||||
use openfut_protocol_blaze::heat2::{Struct, TypeId, Value};
|
||||
|
||||
use super::client_config;
|
||||
use super::config::AdapterConfig;
|
||||
use super::ids::ADVERTISED_COMPONENT_IDS;
|
||||
use super::session::Session;
|
||||
|
||||
fn s(v: impl Into<String>) -> Value {
|
||||
Value::String(v.into())
|
||||
}
|
||||
|
||||
fn i(v: i64) -> Value {
|
||||
Value::Int(v)
|
||||
}
|
||||
|
||||
/// `Blaze::Util::FetchConfigResponse` @0x1448752e0 — a single `CONF`
|
||||
/// map<string,string>.
|
||||
///
|
||||
/// NOT double-nested. The extra nesting exists only inside `PreAuthResponse`,
|
||||
/// where `CONF` is itself a `FetchConfigResponse` whose own single member is
|
||||
/// also called `CONF`. Easy to get wrong.
|
||||
pub fn fetch_config_response(cfid: &str, cfg: &AdapterConfig) -> Struct {
|
||||
let entries = client_config::rows_for(cfid, cfg)
|
||||
.into_iter()
|
||||
.map(|(k, v)| (Value::String(k), Value::String(v)))
|
||||
.collect();
|
||||
Struct::new().with(
|
||||
"CONF",
|
||||
Value::Map {
|
||||
key: TypeId::String,
|
||||
val: TypeId::String,
|
||||
entries,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `Blaze::QosConfigInfo` — 4 members.
|
||||
///
|
||||
/// FIFA 17's descriptor has no `SVID`, unlike Mirror's Edge Catalyst; do not
|
||||
/// add one back from another title's emulator.
|
||||
pub fn qos_config(cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new()
|
||||
.with(
|
||||
"BWPS",
|
||||
Value::Struct(
|
||||
Struct::new()
|
||||
.with("PSA", s(&cfg.endpoints.advertise))
|
||||
.with("PSP", i(cfg.endpoints.qos_port)),
|
||||
),
|
||||
)
|
||||
.with("LNP", i(10))
|
||||
.with(
|
||||
"LTPS",
|
||||
Value::Map {
|
||||
key: TypeId::String,
|
||||
val: TypeId::Struct,
|
||||
entries: vec![],
|
||||
},
|
||||
)
|
||||
.with("TIME", i(5_000_000))
|
||||
}
|
||||
|
||||
/// `Blaze::Util::PreAuthResponse`.
|
||||
pub fn preauth_response(service_name: &str, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("ASRC", s(&id.title_id))
|
||||
.with(
|
||||
"CIDS",
|
||||
Value::List {
|
||||
elem: TypeId::Int,
|
||||
items: ADVERTISED_COMPONENT_IDS.iter().copied().map(i).collect(),
|
||||
},
|
||||
)
|
||||
.with("CLID", s(&id.client_id))
|
||||
.with(
|
||||
"CONF",
|
||||
Value::Struct(fetch_config_response("BlazeSDK", cfg)),
|
||||
)
|
||||
.with("ESRC", s(&id.title_id))
|
||||
.with("INST", s(service_name)) // echo of CDAT.SVCN
|
||||
.with("MAID", i(0))
|
||||
.with("MINR", i(0))
|
||||
.with("NASP", s(&id.namespace))
|
||||
.with("PILD", s(""))
|
||||
.with("PLAT", s(&id.platform))
|
||||
.with("QOSS", Value::Struct(qos_config(cfg)))
|
||||
.with("RSRC", s(&id.title_id))
|
||||
.with("SVER", s(&cfg.server_version))
|
||||
}
|
||||
|
||||
/// `Blaze::Util::PingResponse` @0x144875560 — exactly one member.
|
||||
///
|
||||
/// v2 also sent `TIME`; that is MEC's field, not FIFA 17's.
|
||||
pub fn ping_response(now: i64) -> Struct {
|
||||
Struct::new().with("STIM", i(now))
|
||||
}
|
||||
|
||||
/// `Blaze::CensusData::SubscribeToCensusDataUpdatesResponse` — 3 TimeValues,
|
||||
/// encoded as INT microseconds.
|
||||
///
|
||||
/// These must be non-zero. The client computes `delay_ms = (CNP + NTMT) / 1000`
|
||||
/// and re-arms a resend timer; an empty reply gives delay 0, which lands the job
|
||||
/// on the scheduler's ready list and produces a ~30/s re-subscribe storm that
|
||||
/// hangs the FUT loading screen.
|
||||
pub fn census_subscribe_response() -> Struct {
|
||||
Struct::new()
|
||||
.with("CNP", i(30 * 1_000_000))
|
||||
.with("NTMT", i(90 * 1_000_000))
|
||||
.with("RTMT", i(300 * 1_000_000))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::PersonaDetails` @0x14487cab0 — 6 members.
|
||||
pub fn persona_details(now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("DSNM", s(&id.persona_name))
|
||||
.with("LAST", i(now))
|
||||
.with("PID", i(id.persona_id))
|
||||
.with("PLAT", i(id.client_platform))
|
||||
.with("STAS", i(id.persona_status))
|
||||
.with("XREF", i(id.ext_id))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::UserLoginInfo` @0x14487cb00 — 8 members.
|
||||
///
|
||||
/// `'1CON'` packs to 0x11, which sorts below `'A'` = 0x21, so it leads.
|
||||
pub fn user_login_info(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("1CON", i(0))
|
||||
.with("BUID", i(id.user_id)) // must be non-zero
|
||||
.with("FRST", i(0))
|
||||
.with("KEY", s(&sess.session_key)) // must be non-empty
|
||||
.with("LLOG", i(now))
|
||||
.with("MAIL", s(&id.email))
|
||||
.with("PDTL", Value::Struct(persona_details(now, cfg)))
|
||||
.with("UID", i(id.user_id)) // must be non-zero
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::LoginResponse` @0x14487d170 — exactly 5 members.
|
||||
///
|
||||
/// Diverges from both public MEC emulators, which emit `CNTX`, `ERRC` and a
|
||||
/// top-level `SKEY`. FIFA 17 has none of those: `CNTX`/`ERRC` are the Blaze
|
||||
/// error-metadata block, and the session key lives at `SESS.KEY`.
|
||||
pub fn login_response(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new()
|
||||
.with("ANON", i(0))
|
||||
.with("NTOS", i(0)) // 1 would divert to the legal-doc flow
|
||||
.with("SESS", Value::Struct(user_login_info(sess, now, cfg)))
|
||||
.with("SPAM", i(1))
|
||||
.with("UNDR", i(0))
|
||||
}
|
||||
|
||||
/// ISO-8601 UTC, matching the oracle's `%Y-%m-%dT%H:%M:%SZ`.
|
||||
///
|
||||
/// Hand-rolled from a Unix timestamp to keep this crate free of a date
|
||||
/// dependency for one format string. Proleptic Gregorian, no leap seconds —
|
||||
/// the same calendar `time.gmtime` uses.
|
||||
fn iso8601_utc(unix: i64) -> String {
|
||||
let days = unix.div_euclid(86_400);
|
||||
let secs = unix.rem_euclid(86_400);
|
||||
let (h, mi, sec) = (secs / 3600, (secs % 3600) / 60, secs % 60);
|
||||
|
||||
// Civil-from-days (Howard Hinnant's algorithm), shifted to a March-based year.
|
||||
let z = days + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
|
||||
format!("{y:04}-{m:02}-{d:02}T{h:02}:{mi:02}:{sec:02}Z")
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::AccountInfo` @0x14487c810 — exactly 16 members.
|
||||
///
|
||||
/// The RPC behind the "Unable to retrieve account information" popup: before it
|
||||
/// was implemented, the empty-reply fallback produced an AccountInfo with
|
||||
/// `UID=0`/`CO=""` and the popup appeared one layer later.
|
||||
///
|
||||
/// Member tags come from the reflection tag table @0x1448775a0; wire types from
|
||||
/// each member's subtype descriptor (string subtype 0x144867628 covers ASRC CO
|
||||
/// DOB DTCR LATH LN MAIL PML; the other eight are int/enum). Enum values:
|
||||
/// `STAS` = AccountStatus ACTIVE = 1, `STAT` = EmailStatus VERIFIED = 2,
|
||||
/// `RC` = StatusReason none = 0.
|
||||
pub fn account_info(now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("AMU", i(0))
|
||||
.with("ASRC", s(&id.namespace)) // == PreAuthResponse.NASP
|
||||
.with("CO", s("US"))
|
||||
.with("DOB", s("1990-01-01T00:00:00Z"))
|
||||
.with("DTCR", s("2016-09-01T00:00:00Z"))
|
||||
.with("GOPT", i(0))
|
||||
.with("LATH", s(iso8601_utc(now)))
|
||||
.with("LN", s(&id.locale))
|
||||
.with("MAIL", s(&id.email)) // == LoginResponse.SESS.MAIL
|
||||
.with("PML", s(""))
|
||||
.with("RC", i(0))
|
||||
.with("STAS", i(1))
|
||||
.with("STAT", i(2))
|
||||
.with("TPOT", i(0))
|
||||
.with("UDU", i(0))
|
||||
.with("UID", i(id.user_id)) // == LoginResponse.SESS.UID
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::PersonaInfo` @0x14487c7c0 — 7 members.
|
||||
///
|
||||
/// `STAS` here is PersonaStatus ACTIVE = 2 (table 0x14487ad20) — a different
|
||||
/// enum from AccountInfo's `STAS`, which is AccountStatus ACTIVE = 1. `LADT`'s
|
||||
/// wire type is a best guess (INT timestamp); it is only reachable via
|
||||
/// getPersona/listPersonas, off the critical getAccount path.
|
||||
pub fn persona_info(now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("DSNM", s(&id.persona_name))
|
||||
.with("DTCR", s("2016-09-01T00:00:00Z"))
|
||||
.with("LADT", i(now))
|
||||
.with("NSNM", s(&id.namespace))
|
||||
.with("PID", i(id.persona_id))
|
||||
.with("STAS", i(2))
|
||||
.with("STRC", i(0))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::GetPersonaResponse` @0x14487d1c0 — PINF + UID.
|
||||
pub fn get_persona_response(now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new()
|
||||
.with("PINF", Value::Struct(persona_info(now, cfg)))
|
||||
.with("UID", i(cfg.identity.user_id))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::ListPersonasResponse` @0x14487d210 — one member.
|
||||
pub fn list_personas_response(now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new().with(
|
||||
"PINF",
|
||||
Value::List {
|
||||
elem: TypeId::Struct,
|
||||
items: vec![Value::Struct(persona_info(now, cfg))],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `Blaze::UserSessionLoginInfo` @0x14486f920 — 16 members.
|
||||
///
|
||||
/// A superset of `UserLoginInfo` with the persona fields flattened in rather
|
||||
/// than nested. `KEY` must be byte-identical to `LoginResponse.SESS.KEY`.
|
||||
///
|
||||
/// `CGID` (a connectionGroup ObjectId) is omitted: the OBJID encoding is
|
||||
/// UNVERIFIED and a wrong one desynchronises the parse, while an absent member
|
||||
/// simply keeps its default.
|
||||
pub fn user_session_login_info(sess: &Session, now: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("1CON", i(0))
|
||||
.with("ALOC", i(sess.account_locale)) // echo the client's own locale
|
||||
.with("BUID", i(id.user_id))
|
||||
.with("DSNM", s(&id.persona_name))
|
||||
.with("FRST", i(0))
|
||||
.with("KEY", s(&sess.session_key)) // same string as LoginResponse
|
||||
.with("LAST", i(now))
|
||||
.with("LLOG", i(now))
|
||||
.with("MAIL", s(&id.email))
|
||||
.with("NASP", s(&id.namespace))
|
||||
.with("PID", i(id.persona_id))
|
||||
.with("PLAT", i(id.client_platform))
|
||||
.with("UID", i(id.user_id))
|
||||
.with("USTP", i(id.user_session_type))
|
||||
.with("XREF", i(id.ext_id))
|
||||
}
|
||||
|
||||
/// `Blaze::Util::NetworkQosData` @0x14486e680 — 5 members. `NATT` 0 = OPEN.
|
||||
pub fn network_qos_data() -> Struct {
|
||||
Struct::new()
|
||||
.with("BWHR", i(0))
|
||||
.with("DBPS", i(100_000))
|
||||
.with("NAHR", i(0))
|
||||
.with("NATT", i(0))
|
||||
.with("UBPS", i(100_000))
|
||||
}
|
||||
|
||||
/// `Blaze::UserSessionExtendedData` @0x144870390.
|
||||
///
|
||||
/// Two FIFA-17-specific deltas from the MEC emulators: FIFA HAS `PSLM`
|
||||
/// (latencyList), which they lack, and FIFA carries `BPS` as a top-level string
|
||||
/// whereas they bury it inside the `ADDR` union. Follow FIFA's layout.
|
||||
///
|
||||
/// `ADDR`, `CVAR` and `ULST` are omitted — unverified union/objid encodings.
|
||||
pub fn user_session_extended_data() -> Struct {
|
||||
Struct::new()
|
||||
.with("BPS", s("openfut"))
|
||||
.with("CTY", s("US"))
|
||||
.with(
|
||||
"DMAP",
|
||||
Value::Map {
|
||||
key: TypeId::Int,
|
||||
val: TypeId::Int,
|
||||
entries: vec![],
|
||||
},
|
||||
)
|
||||
.with("HWFG", i(0))
|
||||
.with("ISP", s("OpenFUT"))
|
||||
.with(
|
||||
"PSLM",
|
||||
Value::List {
|
||||
elem: TypeId::Int,
|
||||
items: vec![i(0)],
|
||||
},
|
||||
)
|
||||
.with("QDAT", Value::Struct(network_qos_data()))
|
||||
.with("TZ", s(""))
|
||||
.with("UATT", i(0))
|
||||
}
|
||||
|
||||
/// `Blaze::UserSessionExtendedDataUpdate` @0x1448703e0 — 3 members.
|
||||
pub fn user_session_extended_data_update(cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new()
|
||||
.with("DATA", Value::Struct(user_session_extended_data()))
|
||||
.with("SUBS", i(1))
|
||||
.with("USID", i(cfg.identity.user_id))
|
||||
}
|
||||
|
||||
/// `Blaze::UserIdentification` @0x14486ebc0 — 9 members.
|
||||
pub fn user_identification(sess: &Session, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("AID", i(id.user_id))
|
||||
.with("ALOC", i(sess.account_locale))
|
||||
.with("EXBB", Value::Blob(vec![]))
|
||||
.with("EXID", i(id.ext_id))
|
||||
.with("ID", i(id.user_id))
|
||||
.with("NAME", s(&id.persona_name))
|
||||
.with("NASP", s(&id.namespace))
|
||||
.with("ORIG", i(id.persona_id))
|
||||
.with("PIDI", i(id.persona_id))
|
||||
}
|
||||
|
||||
/// `Blaze::UserData` @0x1448706b0 — payload of the `UserAdded` push.
|
||||
/// `FLGS` is a UserDataFlags bitfield; bit 0 = online/authenticated.
|
||||
pub fn user_data(sess: &Session, cfg: &AdapterConfig) -> Struct {
|
||||
Struct::new()
|
||||
.with("EDAT", Value::Struct(user_session_extended_data()))
|
||||
.with("FLGS", i(3))
|
||||
.with("USER", Value::Struct(user_identification(sess, cfg)))
|
||||
}
|
||||
|
||||
/// `Blaze::Util::PostAuthResponse` @0x144875810 — TELE, TICK, UROP.
|
||||
///
|
||||
/// Telemetry and ticker point at dead local ports on purpose: the client gets a
|
||||
/// well-formed config and then fails to connect quietly, rather than resolving
|
||||
/// a real EA hostname.
|
||||
pub fn post_auth_response(sess: &Session, cfg: &AdapterConfig) -> Struct {
|
||||
let tele = Struct::new()
|
||||
.with("ADRS", s(&cfg.endpoints.advertise))
|
||||
.with("ANON", i(0))
|
||||
.with("DISA", s(""))
|
||||
.with("EDCT", i(0))
|
||||
.with("FILT", s(""))
|
||||
.with("LOC", i(sess.account_locale))
|
||||
.with("MINR", i(0))
|
||||
.with("NOOK", s(""))
|
||||
.with("PORT", i(cfg.endpoints.telemetry_port))
|
||||
.with("SDLY", i(15_000))
|
||||
.with("SESS", s(&sess.session_key)) // same key as login
|
||||
.with("SKEY", s(""))
|
||||
.with("SPCT", i(75))
|
||||
.with("STIM", s(""))
|
||||
.with("SVNM", s("telemetry-openfut"));
|
||||
|
||||
let tick = Struct::new()
|
||||
.with("ADRS", s(&cfg.endpoints.advertise))
|
||||
.with("PORT", i(cfg.endpoints.ticker_port))
|
||||
.with("SKEY", s(""));
|
||||
|
||||
let urop = Struct::new()
|
||||
.with("TMOP", i(0))
|
||||
.with("UID", i(cfg.identity.user_id));
|
||||
|
||||
Struct::new()
|
||||
.with("TELE", Value::Struct(tele))
|
||||
.with("TICK", Value::Struct(tick))
|
||||
.with("UROP", Value::Struct(urop))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::Entitlement` @0x14487d490 — 16 members.
|
||||
///
|
||||
/// FUT's client-side filter (`onListEntitlements` @0x146f27440) keeps a record
|
||||
/// only if `GNAM` contains `"FIFA17PCBoxContent"` or `"FIFA16PC"`, `TAG` is
|
||||
/// non-empty, and `STAT == 1`. A plain `"FIFA17PC"` group matched neither
|
||||
/// needle and produced an empty store.
|
||||
///
|
||||
/// `PRID`/`GNAM`/`TAG` must contain no `'|'` and no `'/'`: the client
|
||||
/// re-serialises them as `PRID|GNAM|TAG|UCNT/`.
|
||||
pub fn entitlement(group: &str, tag: &str, eid: i64, cfg: &AdapterConfig) -> Struct {
|
||||
let id = &cfg.identity;
|
||||
Struct::new()
|
||||
.with("DEVI", s(""))
|
||||
.with("GDAY", s("2016-09-01T00:00:00Z"))
|
||||
.with("GNAM", s(group))
|
||||
.with("ID", i(eid))
|
||||
.with("ISCO", i(0))
|
||||
.with("PID", i(id.persona_id))
|
||||
.with("PJID", s(&id.content_id))
|
||||
.with("PRCA", i(2))
|
||||
.with("PRID", s(&id.content_id))
|
||||
.with("STAT", i(1)) // must be 1 or FUT drops it
|
||||
.with("STRC", i(0))
|
||||
.with("TAG", s(tag)) // must be non-empty
|
||||
.with("TDAY", s(""))
|
||||
.with("TYPE", i(1))
|
||||
.with("UCNT", i(0))
|
||||
.with("VER", i(1))
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::Entitlements` @0x14487d4e0 — single member `NLST`.
|
||||
///
|
||||
/// Emits BOTH accepted groups so the entitlement manager's "loaded" flag
|
||||
/// (`byte[entMgr+0x88]`) flips however the client asks.
|
||||
pub fn entitlements_response(cfg: &AdapterConfig) -> Struct {
|
||||
let tag = &cfg.identity.entitlement_tag;
|
||||
Struct::new().with(
|
||||
"NLST",
|
||||
Value::List {
|
||||
elem: TypeId::Struct,
|
||||
items: vec![
|
||||
Value::Struct(entitlement("FIFA17PCBoxContent", tag, 1, cfg)),
|
||||
Value::Struct(entitlement("FIFA16PC", tag, 2, cfg)),
|
||||
],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// `Blaze::Authentication::GetAuthTokenResponse` @0x14487d080 — one member.
|
||||
pub fn get_auth_token_response(sess: &Session) -> Struct {
|
||||
Struct::new().with("AUTH", s(sess.auth_token()))
|
||||
}
|
||||
|
||||
/// `Util::userSettingsLoad` response.
|
||||
///
|
||||
/// TODO(verify): the response descriptor was never reflected. Both independent
|
||||
/// clean-room emulators use a single `DATA` string, and an unknown-tag payload
|
||||
/// is ignored rather than fatal, so an empty `DATA` is the safe minimum — the
|
||||
/// client falls back to its defaults.
|
||||
pub fn user_settings_response() -> Struct {
|
||||
Struct::new().with("DATA", s(""))
|
||||
}
|
||||
|
||||
/// `AssociationLists::getLists` response.
|
||||
///
|
||||
/// TODO(verify): FIFA's association-list names are NOT known — do not invent
|
||||
/// them. An empty list is well-formed and means "this user has no association
|
||||
/// lists", which is true offline.
|
||||
pub fn get_lists_response() -> Struct {
|
||||
Struct::new().with(
|
||||
"LMAP",
|
||||
Value::List {
|
||||
elem: TypeId::Struct,
|
||||
items: vec![],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn cfg() -> AdapterConfig {
|
||||
AdapterConfig::loopback()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iso8601_matches_known_instants() {
|
||||
assert_eq!(iso8601_utc(0), "1970-01-01T00:00:00Z");
|
||||
assert_eq!(iso8601_utc(1_754_870_400), "2025-08-11T00:00:00Z");
|
||||
// A leap day, to exercise the civil-from-days branch.
|
||||
assert_eq!(iso8601_utc(1_709_164_800), "2024-02-29T00:00:00Z");
|
||||
assert_eq!(iso8601_utc(951_782_400), "2000-02-29T00:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_response_has_exactly_five_members() {
|
||||
let sess = Session::new("k", 0);
|
||||
assert_eq!(login_response(&sess, 0, &cfg()).len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_info_has_exactly_sixteen_members() {
|
||||
assert_eq!(account_info(0, &cfg()).len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_key_appears_identically_in_all_three_places() {
|
||||
let sess = Session::new("THE-KEY", 0);
|
||||
let c = cfg();
|
||||
|
||||
let login = login_response(&sess, 1, &c);
|
||||
let in_login = login
|
||||
.get("SESS")
|
||||
.and_then(Value::as_struct)
|
||||
.and_then(|s| s.get("KEY"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap();
|
||||
|
||||
let notify = user_session_login_info(&sess, 1, &c);
|
||||
let in_notify = notify.get("KEY").and_then(Value::as_str).unwrap();
|
||||
|
||||
let post = post_auth_response(&sess, &c);
|
||||
let in_post = post
|
||||
.get("TELE")
|
||||
.and_then(Value::as_struct)
|
||||
.and_then(|s| s.get("SESS"))
|
||||
.and_then(Value::as_str)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(in_login, "THE-KEY");
|
||||
assert_eq!(in_notify, "THE-KEY");
|
||||
assert_eq!(in_post, "THE-KEY");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_is_consistent_between_login_and_account_info() {
|
||||
let sess = Session::new("k", 0);
|
||||
let c = cfg();
|
||||
let acct = account_info(0, &c);
|
||||
let sess_info = user_login_info(&sess, 0, &c);
|
||||
|
||||
assert_eq!(
|
||||
acct.get("MAIL").and_then(Value::as_str),
|
||||
sess_info.get("MAIL").and_then(Value::as_str)
|
||||
);
|
||||
assert_eq!(
|
||||
acct.get("UID").and_then(Value::as_int),
|
||||
sess_info.get("UID").and_then(Value::as_int)
|
||||
);
|
||||
assert_eq!(
|
||||
acct.get("ASRC").and_then(Value::as_str),
|
||||
Some(c.identity.namespace.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entitlement_groups_match_the_clients_needles() {
|
||||
let c = cfg();
|
||||
let list = entitlements_response(&c);
|
||||
let items = match list.get("NLST") {
|
||||
Some(Value::List { items, .. }) => items,
|
||||
_ => panic!("NLST is a list"),
|
||||
};
|
||||
assert_eq!(items.len(), 2);
|
||||
for item in items {
|
||||
let e = item.as_struct().unwrap();
|
||||
let gnam = e.get("GNAM").and_then(Value::as_str).unwrap();
|
||||
assert!(
|
||||
gnam.contains("FIFA17PCBoxContent") || gnam.contains("FIFA16PC"),
|
||||
"group {gnam} matches neither client needle"
|
||||
);
|
||||
assert_eq!(e.get("STAT").and_then(Value::as_int), Some(1));
|
||||
assert!(!e.get("TAG").and_then(Value::as_str).unwrap().is_empty());
|
||||
// The client re-serialises these delimited; a separator would corrupt it.
|
||||
for tag in ["PRID", "GNAM", "TAG"] {
|
||||
let v = e.get(tag).and_then(Value::as_str).unwrap();
|
||||
assert!(
|
||||
!v.contains('|') && !v.contains('/'),
|
||||
"{tag} has a separator"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn census_periods_are_non_zero() {
|
||||
// Zero here is the ~30/s storm that hangs the FUT loading screen.
|
||||
let r = census_subscribe_response();
|
||||
assert!(r.get("CNP").and_then(Value::as_int).unwrap() > 0);
|
||||
assert!(r.get("NTMT").and_then(Value::as_int).unwrap() > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preauth_echoes_the_requested_service_name() {
|
||||
let p = preauth_response("fifa-2017-pc-de", &cfg());
|
||||
assert_eq!(
|
||||
p.get("INST").and_then(Value::as_str),
|
||||
Some("fifa-2017-pc-de")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extended_data_omits_the_unverified_members() {
|
||||
// Absent is safe; a wrong union/objid encoding breaks the whole parse.
|
||||
let d = user_session_extended_data();
|
||||
for absent in ["ADDR", "CVAR", "ULST"] {
|
||||
assert!(d.get(absent).is_none(), "{absent} must stay omitted");
|
||||
}
|
||||
assert!(
|
||||
d.get("PSLM").is_some(),
|
||||
"PSLM is FIFA-specific and required"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//! Per-connection Blaze session state.
|
||||
//!
|
||||
//! Note how little there is: a session key, the client's locale, the echoed
|
||||
//! service name, an auth code and a logged-in flag. That is the whole of it.
|
||||
//!
|
||||
//! This is the point of the adapter boundary. Blaze is an auth/session/config
|
||||
//! protocol — coins, packs, clubs, squads and the rest of the FUT domain never
|
||||
//! appear on this wire, so there is nothing here tempting the adapter into
|
||||
//! becoming a second backend. When UTAS is migrated that discipline will need
|
||||
//! actively defending; here it comes for free.
|
||||
|
||||
/// State carried across RPCs on one Blaze connection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Session {
|
||||
/// Minted once per connection. Must appear byte-identically in
|
||||
/// `LoginResponse.SESS.KEY`, the `UserAuthenticated` push, and
|
||||
/// `PostAuthResponse.TELE.SESS`.
|
||||
pub session_key: String,
|
||||
/// Whatever `LoginRequest.AUTH` carried; echoed back by `getAuthToken`.
|
||||
pub auth_code: String,
|
||||
/// Packed four-char locale, seeded from config and overwritten by the
|
||||
/// client's own preAuth `LANG`/`LOC`.
|
||||
pub account_locale: i64,
|
||||
/// Echoed back as `PreAuthResponse.INST`.
|
||||
pub service_name: String,
|
||||
pub logged_in: bool,
|
||||
pub login_time: i64,
|
||||
}
|
||||
|
||||
/// The oracle's default service name when preAuth carries no `CDAT.SVCN`.
|
||||
pub const DEFAULT_SERVICE_NAME: &str = "fifa-2017-pc";
|
||||
|
||||
impl Session {
|
||||
/// Start a session with an explicit key.
|
||||
///
|
||||
/// The key is injected rather than generated internally so it can be made
|
||||
/// deterministic for differential tests — it appears verbatim in three
|
||||
/// different responses, so a self-generated one would make every login
|
||||
/// fixture unreproducible.
|
||||
pub fn new(session_key: impl Into<String>, account_locale: i64) -> Session {
|
||||
Session {
|
||||
session_key: session_key.into(),
|
||||
auth_code: String::new(),
|
||||
account_locale,
|
||||
service_name: DEFAULT_SERVICE_NAME.into(),
|
||||
logged_in: false,
|
||||
login_time: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The token `getAuthToken` returns.
|
||||
///
|
||||
/// Before login there is no auth code, so the oracle synthesises one from
|
||||
/// the session key. Reproduced exactly, including the 16-character slice.
|
||||
pub fn auth_token(&self) -> String {
|
||||
if !self.auth_code.is_empty() {
|
||||
return self.auth_code.clone();
|
||||
}
|
||||
// Byte slicing is safe here in practice (session keys are ASCII), but
|
||||
// char_indices keeps it correct for any injected key.
|
||||
let cut = self
|
||||
.session_key
|
||||
.char_indices()
|
||||
.nth(16)
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(self.session_key.len());
|
||||
format!("OPENFUT-{}", &self.session_key[..cut])
|
||||
}
|
||||
}
|
||||
|
||||
/// Mint a Blaze-shaped session key: 16 hex, an underscore, then 44 alphanumerics.
|
||||
///
|
||||
/// The client never validates the format — one public emulator ships the
|
||||
/// literal `"0"` — so this only has to be stable within a connection. Callers
|
||||
/// supply the randomness so this crate needs no RNG dependency and stays
|
||||
/// deterministic under test.
|
||||
pub fn format_session_key(high_bits: u64, tail: &str) -> String {
|
||||
format!("{high_bits:016x}_{tail}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn synthesises_a_token_before_login() {
|
||||
let s = Session::new("0123456789abcdef_TAIL", 0);
|
||||
assert_eq!(s.auth_token(), "OPENFUT-0123456789abcdef");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn echoes_the_login_auth_code_afterwards() {
|
||||
let mut s = Session::new("0123456789abcdef_TAIL", 0);
|
||||
s.auth_code = "REAL-CODE".into();
|
||||
assert_eq!(s.auth_token(), "REAL-CODE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_session_keys_do_not_panic() {
|
||||
assert_eq!(Session::new("abc", 0).auth_token(), "OPENFUT-abc");
|
||||
assert_eq!(Session::new("", 0).auth_token(), "OPENFUT-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_key_has_the_blaze_shape() {
|
||||
let k = format_session_key(0x0123456789abcdef, &"x".repeat(44));
|
||||
assert_eq!(k.len(), 16 + 1 + 44);
|
||||
assert!(k.starts_with("0123456789abcdef_"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
//! FIFA 17 **card-definition identity catalog** and owned-item **wire-id policy**.
|
||||
//!
|
||||
//! Two distinct identities (never conflate them):
|
||||
//!
|
||||
//! * **Card definition** — *what card is this?* A semantic OpenFUT
|
||||
//! `CardDefinitionId` maps to a FIFA 17 render identity here:
|
||||
//! `resource_id = (version << 24) | asset_id`. The client resolves
|
||||
//! `resource_id & 0xFFFFFF` (= `asset_id`) against its own local player DB;
|
||||
//! an invented id renders a blank card, so this catalog is authored from
|
||||
//! verified FIFA 17 data (`pool.json` player asset ids), never guessed.
|
||||
//! * **Owned-item instance** — *which exact copy?* A monotonic integer wire id,
|
||||
//! allocated per account by the generic external-identity store; this module
|
||||
//! only holds the FIFA 17 numeric **policy** ([`Fifa17WireItemIdPolicy`]).
|
||||
//!
|
||||
//! The catalog is game DATA (a versioned JSON file), not deployment config, and
|
||||
//! not a generic-Core concern. Unknown definitions resolve to `None` — callers
|
||||
//! drop them, never fabricate an asset id.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
|
||||
/// The FIFA 17 render identity of a card definition. `version` is the high byte
|
||||
/// of `resource_id`; `asset_id` (the low 24 bits) is the real FIFA player id.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Fifa17CardIdentity {
|
||||
pub asset_id: u32,
|
||||
pub version: u8,
|
||||
pub resource_id: u32,
|
||||
/// FIFA wire `rareflag` (rare/special card TYPE). Carried so specials render
|
||||
/// as specials; observed metadata, not a guessed label.
|
||||
pub rareflag: i64,
|
||||
/// Content class of this definition. A catalog authored before this field
|
||||
/// existed defaults to [`ContentKind::Player`] (backward compatible).
|
||||
pub kind: ContentKind,
|
||||
/// FIFA `cardsubtypeid` for a non-player definition (consumable family /
|
||||
/// staff role), `0` for a player or when absent.
|
||||
pub subtype: i64,
|
||||
}
|
||||
|
||||
/// The FIFA 17 numeric namespace policy for owned-item wire ids.
|
||||
///
|
||||
/// Owned-item ids are monotonic from `OWNED_ITEM_BASE + 1` (= 100_000_001,
|
||||
/// matching the oracle's `ITEM_ID_BASE = 100_000_000` and its first minted id),
|
||||
/// staying below the synthetic-overlay ranges the responder uses (≥ 9e8). The
|
||||
/// generic store enforces monotonicity/uniqueness; this type supplies the game,
|
||||
/// entity-kind and base floor.
|
||||
pub struct Fifa17WireItemIdPolicy;
|
||||
|
||||
impl Fifa17WireItemIdPolicy {
|
||||
pub const GAME: &'static str = "fifa17";
|
||||
pub const OWNED_ITEM_KIND: &'static str = "owned-item";
|
||||
pub const OWNED_ITEM_BASE: i64 = 100_000_000;
|
||||
|
||||
/// First owned-item wire id (`100_000_001`).
|
||||
pub fn owned_item_base_floor() -> i64 {
|
||||
Self::OWNED_ITEM_BASE + 1
|
||||
}
|
||||
}
|
||||
|
||||
/// Highest representable asset id (24 bits); above this `version` would be
|
||||
/// clobbered in `resource_id`.
|
||||
const MAX_ASSET_ID: u32 = 0x00FF_FFFF;
|
||||
const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
/// Catalog load/validation errors — all explicit, no silent fallback.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum CatalogError {
|
||||
BadSchemaVersion(u32),
|
||||
WrongGame(String),
|
||||
AssetTooLarge {
|
||||
card_id: String,
|
||||
asset_id: u32,
|
||||
},
|
||||
DuplicateResource {
|
||||
resource_id: u32,
|
||||
first: String,
|
||||
second: String,
|
||||
},
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CatalogError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CatalogError::BadSchemaVersion(v) => {
|
||||
write!(f, "unsupported catalog schema_version {v}")
|
||||
}
|
||||
CatalogError::WrongGame(g) => write!(f, "catalog game is '{g}', expected 'fifa17'"),
|
||||
CatalogError::AssetTooLarge { card_id, asset_id } => {
|
||||
write!(f, "card '{card_id}' asset_id {asset_id} exceeds 24 bits")
|
||||
}
|
||||
CatalogError::DuplicateResource {
|
||||
resource_id,
|
||||
first,
|
||||
second,
|
||||
} => write!(
|
||||
f,
|
||||
"resource_id {resource_id} claimed by both '{first}' and '{second}'"
|
||||
),
|
||||
CatalogError::Parse(e) => write!(f, "catalog parse error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for CatalogError {}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawCatalog {
|
||||
schema_version: u32,
|
||||
game: String,
|
||||
#[serde(default)]
|
||||
cards: std::collections::BTreeMap<String, RawCard>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RawCard {
|
||||
asset_id: u32,
|
||||
#[serde(default)]
|
||||
version: u8,
|
||||
/// Absent in a base-only catalog → default 1 (rare), preserving prior wire
|
||||
/// behaviour; the production catalog carries the observed value.
|
||||
#[serde(default = "default_rareflag")]
|
||||
rareflag: i64,
|
||||
/// Content class token ("player"|"consumable"|"staff"). Absent → default
|
||||
/// (empty) → [`ContentKind::Player`], so existing player-only catalogs load
|
||||
/// unchanged.
|
||||
#[serde(default)]
|
||||
kind: String,
|
||||
/// FIFA `cardsubtypeid` for a non-player entry; absent → `0`.
|
||||
#[serde(default)]
|
||||
subtype: i64,
|
||||
}
|
||||
|
||||
fn default_rareflag() -> i64 {
|
||||
1
|
||||
}
|
||||
|
||||
/// A loaded, validated FIFA 17 card-definition identity catalog.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Fifa17CardCatalog {
|
||||
by_card: HashMap<String, Fifa17CardIdentity>,
|
||||
/// Reverse index: full versioned `resource_id` → the card definition id. The
|
||||
/// wire carries a `resourceId`; the synthetic market must mint the
|
||||
/// authoritative Core `card_id`, never the raw FIFA number.
|
||||
by_resource: HashMap<u32, String>,
|
||||
}
|
||||
|
||||
impl Fifa17CardCatalog {
|
||||
/// Parse + validate a catalog document. Rejects wrong schema/game, an
|
||||
/// asset id that would overflow into the version byte, and two card ids
|
||||
/// claiming the same `resource_id` (a semantic-vs-FIFA identity conflict).
|
||||
pub fn from_json_str(s: &str) -> Result<Self, CatalogError> {
|
||||
let raw: RawCatalog =
|
||||
serde_json::from_str(s).map_err(|e| CatalogError::Parse(e.to_string()))?;
|
||||
if raw.schema_version != SCHEMA_VERSION {
|
||||
return Err(CatalogError::BadSchemaVersion(raw.schema_version));
|
||||
}
|
||||
if raw.game != Fifa17WireItemIdPolicy::GAME {
|
||||
return Err(CatalogError::WrongGame(raw.game));
|
||||
}
|
||||
let mut by_card = HashMap::new();
|
||||
let mut by_resource: HashMap<u32, String> = HashMap::new();
|
||||
for (card_id, rc) in raw.cards {
|
||||
if rc.asset_id > MAX_ASSET_ID {
|
||||
return Err(CatalogError::AssetTooLarge {
|
||||
card_id,
|
||||
asset_id: rc.asset_id,
|
||||
});
|
||||
}
|
||||
let resource_id = ((rc.version as u32) << 24) | rc.asset_id;
|
||||
if let Some(first) = by_resource.get(&resource_id) {
|
||||
return Err(CatalogError::DuplicateResource {
|
||||
resource_id,
|
||||
first: first.clone(),
|
||||
second: card_id,
|
||||
});
|
||||
}
|
||||
by_resource.insert(resource_id, card_id.clone());
|
||||
by_card.insert(
|
||||
card_id,
|
||||
Fifa17CardIdentity {
|
||||
asset_id: rc.asset_id,
|
||||
version: rc.version,
|
||||
resource_id,
|
||||
rareflag: rc.rareflag,
|
||||
kind: ContentKind::from_str(&rc.kind),
|
||||
subtype: rc.subtype,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(Fifa17CardCatalog {
|
||||
by_card,
|
||||
by_resource,
|
||||
})
|
||||
}
|
||||
|
||||
/// Load a catalog from a JSON file.
|
||||
pub fn from_file(path: &std::path::Path) -> Result<Self, CatalogError> {
|
||||
let raw = std::fs::read_to_string(path)
|
||||
.map_err(|e| CatalogError::Parse(format!("reading {}: {e}", path.display())))?;
|
||||
Self::from_json_str(&raw)
|
||||
}
|
||||
|
||||
/// The FIFA 17 identity for a definition, or `None` (never a fabricated id).
|
||||
pub fn lookup(&self, card_id: &str) -> Option<Fifa17CardIdentity> {
|
||||
self.by_card.get(card_id).copied()
|
||||
}
|
||||
|
||||
/// Reverse a FIFA wire `resource_id` (full versioned id) to its authoritative
|
||||
/// Core `card_id`, or `None` (never a fabricated/heuristic id). Used by the
|
||||
/// synthetic transfer market so a purchase mints real Core content.
|
||||
pub fn card_id_for_resource(&self, resource_id: u32) -> Option<&str> {
|
||||
self.by_resource.get(&resource_id).map(String::as_str)
|
||||
}
|
||||
|
||||
/// Classify a `card_id` as player/consumable/staff. An unknown definition is
|
||||
/// [`ContentKind::Player`] — the neutral, backward-compatible default (an
|
||||
/// un-catalogued id was always treated as a player-shaped card).
|
||||
pub fn kind_of(&self, card_id: &str) -> ContentKind {
|
||||
self.by_card
|
||||
.get(card_id)
|
||||
.map(|c| c.kind)
|
||||
.unwrap_or(ContentKind::Player)
|
||||
}
|
||||
|
||||
/// The FIFA `cardsubtypeid` for a definition, or `0` if unknown / a player.
|
||||
pub fn subtype_of(&self, card_id: &str) -> i64 {
|
||||
self.by_card.get(card_id).map(|c| c.subtype).unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.by_card.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.by_card.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resource_id_composition_base_and_special() {
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"card_base":{"asset_id":20801},
|
||||
"card_totw":{"asset_id":20801,"version":3}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
// version 0 -> resource_id == asset_id
|
||||
let base = cat.lookup("card_base").unwrap();
|
||||
assert_eq!(base.version, 0);
|
||||
assert_eq!(base.resource_id, 20801);
|
||||
assert_eq!(base.resource_id, base.asset_id);
|
||||
// version 3 -> high byte set; same base player, different FIFA card
|
||||
let totw = cat.lookup("card_totw").unwrap();
|
||||
assert_eq!(totw.asset_id, 20801, "asset_id (base player) unchanged");
|
||||
assert_eq!(totw.resource_id, (3u32 << 24) | 20801);
|
||||
assert_ne!(base.resource_id, totw.resource_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_card_is_none() {
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{"card_base":{"asset_id":1}}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cat.lookup("card_missing"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_cards_same_resource_is_a_conflict() {
|
||||
let err = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"card_a":{"asset_id":20801},
|
||||
"card_b":{"asset_id":20801}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
CatalogError::DuplicateResource {
|
||||
resource_id: 20801,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_and_game_are_validated() {
|
||||
assert_eq!(
|
||||
Fifa17CardCatalog::from_json_str(r#"{"schema_version":2,"game":"fifa17","cards":{}}"#)
|
||||
.unwrap_err(),
|
||||
CatalogError::BadSchemaVersion(2)
|
||||
);
|
||||
assert_eq!(
|
||||
Fifa17CardCatalog::from_json_str(r#"{"schema_version":1,"game":"fifa23","cards":{}}"#)
|
||||
.unwrap_err(),
|
||||
CatalogError::WrongGame("fifa23".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn asset_exceeding_24_bits_is_rejected() {
|
||||
let err = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{"c":{"asset_id":16777216}}}"#,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
CatalogError::AssetTooLarge {
|
||||
asset_id: 16_777_216,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_is_a_parse_error() {
|
||||
assert!(matches!(
|
||||
Fifa17CardCatalog::from_json_str("{not json").unwrap_err(),
|
||||
CatalogError::Parse(_)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_reload() {
|
||||
let doc = r#"{"schema_version":1,"game":"fifa17","cards":{"a":{"asset_id":10},"b":{"asset_id":20,"version":1}}}"#;
|
||||
let c1 = Fifa17CardCatalog::from_json_str(doc).unwrap();
|
||||
let c2 = Fifa17CardCatalog::from_json_str(doc).unwrap();
|
||||
assert_eq!(c1.lookup("a"), c2.lookup("a"));
|
||||
assert_eq!(c1.lookup("b"), c2.lookup("b"));
|
||||
assert_eq!(c1.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_id_policy_constants() {
|
||||
assert_eq!(Fifa17WireItemIdPolicy::GAME, "fifa17");
|
||||
assert_eq!(Fifa17WireItemIdPolicy::OWNED_ITEM_KIND, "owned-item");
|
||||
assert_eq!(Fifa17WireItemIdPolicy::owned_item_base_floor(), 100_000_001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_the_committed_generated_catalog() {
|
||||
// The generated base-card catalog (scripts/seed_fifa17_cards.py) must be
|
||||
// loadable by this adapter and carry real asset identities.
|
||||
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("data/fifa17-card-identities.json");
|
||||
if !path.exists() {
|
||||
eprintln!("skip: {} not generated", path.display());
|
||||
return;
|
||||
}
|
||||
let cat = Fifa17CardCatalog::from_file(&path).expect("load committed catalog");
|
||||
assert!(
|
||||
cat.len() > 17_000,
|
||||
"full FIFA17 base pool, got {}",
|
||||
cat.len()
|
||||
);
|
||||
// Ronaldo (asset 20801), version 0 => resource_id == asset_id.
|
||||
let ron = cat
|
||||
.lookup("fifa17_20801")
|
||||
.expect("known base asset present");
|
||||
assert_eq!(ron.asset_id, 20801);
|
||||
assert_eq!(ron.version, 0);
|
||||
assert_eq!(ron.resource_id, 20801);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_catalog_without_kind_loads_as_player() {
|
||||
// A pre-taxonomy catalog (no `kind`/`subtype`) must load unchanged and
|
||||
// classify every entry as a player, with subtype 0.
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"fifa17_20801":{"asset_id":20801},
|
||||
"fifa17_176580":{"asset_id":176580,"version":5,"rareflag":3}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let base = cat.lookup("fifa17_20801").unwrap();
|
||||
assert_eq!(base.kind, ContentKind::Player);
|
||||
assert_eq!(base.subtype, 0);
|
||||
assert_eq!(base.rareflag, 1, "absent rareflag still defaults to 1");
|
||||
assert_eq!(cat.kind_of("fifa17_20801"), ContentKind::Player);
|
||||
assert_eq!(cat.kind_of("fifa17_176580"), ContentKind::Player);
|
||||
// Unknown id -> neutral Player default.
|
||||
assert_eq!(cat.kind_of("fifa17_missing"), ContentKind::Player);
|
||||
assert_eq!(cat.subtype_of("fifa17_missing"), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_and_subtype_are_parsed_for_non_player_entries() {
|
||||
let cat = Fifa17CardCatalog::from_json_str(
|
||||
r#"{"schema_version":1,"game":"fifa17","cards":{
|
||||
"fifa17_20801":{"asset_id":20801,"kind":"player","subtype":0},
|
||||
"fifa17_5003012":{"asset_id":5003012,"kind":"consumable","subtype":54,"rareflag":0},
|
||||
"fifa17_3000083":{"asset_id":3000083,"kind":"staff","subtype":8,"rareflag":0}
|
||||
}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(cat.kind_of("fifa17_20801"), ContentKind::Player);
|
||||
assert_eq!(cat.kind_of("fifa17_5003012"), ContentKind::Consumable);
|
||||
assert_eq!(cat.subtype_of("fifa17_5003012"), 54);
|
||||
assert_eq!(cat.kind_of("fifa17_3000083"), ContentKind::Staff);
|
||||
assert_eq!(cat.subtype_of("fifa17_3000083"), 8);
|
||||
assert_eq!(cat.lookup("fifa17_5003012").unwrap().rareflag, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! Shape OpenFUT Core's semantic owned inventory into the FIFA 17 `/club`
|
||||
//! response envelope `{"itemData":[ <player item>, … ]}`.
|
||||
//!
|
||||
//! This module owns only the **`/club` envelope**; the per-item shape lives in
|
||||
//! the shared [`crate::fut::item`] primitive so `/club` and squad projection
|
||||
//! emit byte-identical items. Items whose real FIFA asset id is unknown are
|
||||
//! **dropped and counted** here (a collection may omit an unrenderable card);
|
||||
//! squad projection, which cannot omit a starter, refuses instead.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item::shape_item;
|
||||
// Re-exported so existing `club_response::{…}` callers keep working; the types
|
||||
// are now defined once in `fut::item`.
|
||||
pub use crate::fut::item::{CoreOwnedItem, Fifa17Identity, ItemIdentityResolver, ShapeStats};
|
||||
|
||||
/// Shape the whole `/club` response. Items without a resolvable real asset id
|
||||
/// are dropped (counted in `ShapeStats`), never emitted with a fabricated id.
|
||||
pub fn shape_club_response<I: ItemIdentityResolver + ?Sized>(
|
||||
items: &[CoreOwnedItem],
|
||||
ent: &impl ReverseEntityResolver,
|
||||
ident: &I,
|
||||
) -> (Value, ShapeStats) {
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
let mut stats = ShapeStats::default();
|
||||
for item in items {
|
||||
// Exclude non-player content (consumables/staff): a `/club` player list
|
||||
// must never render them as 0-rated players. Counted, never emitted.
|
||||
if ident.kind_of(item) != ContentKind::Player {
|
||||
stats.excluded_non_player += 1;
|
||||
continue;
|
||||
}
|
||||
match ident.resolve(item) {
|
||||
Some(id) => {
|
||||
out.push(shape_item(item, id, ent));
|
||||
stats.emitted += 1;
|
||||
}
|
||||
None => stats.dropped_no_asset += 1,
|
||||
}
|
||||
}
|
||||
(json!({ "itemData": out }), stats)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fut::entities::Fifa17Entities;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn entities() -> Fifa17Entities {
|
||||
Fifa17Entities::from_maps(
|
||||
HashMap::from([(13, "Premier League".to_string())]),
|
||||
HashMap::from([(52, "Argentina".to_string())]),
|
||||
HashMap::from([(5, "Chelsea".to_string())]),
|
||||
)
|
||||
}
|
||||
|
||||
fn item(
|
||||
owned: &str,
|
||||
card: &str,
|
||||
rating: u8,
|
||||
pos: &str,
|
||||
nation: &str,
|
||||
league: &str,
|
||||
club: &str,
|
||||
) -> CoreOwnedItem {
|
||||
CoreOwnedItem {
|
||||
owned_card_id: owned.into(),
|
||||
card_id: card.into(),
|
||||
rating,
|
||||
position: pos.into(),
|
||||
nation: nation.into(),
|
||||
league: league.into(),
|
||||
club: club.into(),
|
||||
attributes: [90, 88, 70, 85, 40, 78],
|
||||
}
|
||||
}
|
||||
|
||||
/// Test resolver: card_id -> real asset id, item_id from a table. Stands in
|
||||
/// for the (unresolved-in-production) Core-card→asset mapping.
|
||||
struct MapIdentity(HashMap<String, Fifa17Identity>);
|
||||
impl ItemIdentityResolver for MapIdentity {
|
||||
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
self.0.get(&it.card_id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_item_with_full_field_set_and_reverse_ids() {
|
||||
let ent = entities();
|
||||
let ident = MapIdentity(HashMap::from([(
|
||||
"card_ch_1".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000001,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
)]));
|
||||
let items = vec![item(
|
||||
"oc1",
|
||||
"card_ch_1",
|
||||
86,
|
||||
"CDM",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
)];
|
||||
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
||||
assert_eq!(stats.emitted, 1);
|
||||
assert_eq!(stats.dropped_no_asset, 0);
|
||||
let it = &body["itemData"][0];
|
||||
assert_eq!(it["id"], 100000001);
|
||||
assert_eq!(it["resourceId"], 20801);
|
||||
assert_eq!(it["assetId"], 20801);
|
||||
assert_eq!(
|
||||
it["definitionId"], 20801,
|
||||
"version byte 0 => resourceId==assetId==definitionId"
|
||||
);
|
||||
assert_eq!(it["rating"], 86);
|
||||
assert_eq!(it["preferredPosition"], "CDM");
|
||||
assert_eq!(it["leagueId"], 13);
|
||||
assert_eq!(it["teamid"], 5);
|
||||
assert_eq!(it["nation"], 52);
|
||||
assert_eq!(it["itemType"], "player");
|
||||
assert_eq!(it["rareflag"], 1);
|
||||
assert_eq!(it["contract"], 7);
|
||||
assert_eq!(it["fitness"], 99);
|
||||
assert_eq!(it["attributeList"].as_array().unwrap().len(), 6);
|
||||
assert_eq!(it["attributeList"][0], json!({"index":0,"value":90}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_items_without_a_real_asset_id_never_faking() {
|
||||
let ent = entities();
|
||||
// Empty identity map == the current synthetic-catalogue reality.
|
||||
let ident = MapIdentity(HashMap::new());
|
||||
let items = vec![item(
|
||||
"oc1",
|
||||
"card_pl_001",
|
||||
84,
|
||||
"ST",
|
||||
"England",
|
||||
"Premier League",
|
||||
"Northgate United",
|
||||
)];
|
||||
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
||||
assert_eq!(stats.emitted, 0);
|
||||
assert_eq!(stats.dropped_no_asset, 1);
|
||||
assert_eq!(
|
||||
body["itemData"].as_array().unwrap().len(),
|
||||
0,
|
||||
"no fabricated ids emitted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_entity_names_become_neutral_zero_not_dropped() {
|
||||
let ent = entities();
|
||||
let ident = MapIdentity(HashMap::from([(
|
||||
"card_x".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000002,
|
||||
asset_id: 158023,
|
||||
resource_id: 158023,
|
||||
rareflag: 1,
|
||||
},
|
||||
)]));
|
||||
// Synthetic club "Northgate United" has no FIFA team id.
|
||||
let items = vec![item(
|
||||
"oc2",
|
||||
"card_x",
|
||||
84,
|
||||
"ST",
|
||||
"England",
|
||||
"Premier League",
|
||||
"Northgate United",
|
||||
)];
|
||||
let (body, _) = shape_club_response(&items, &ent, &ident);
|
||||
let it = &body["itemData"][0];
|
||||
assert_eq!(
|
||||
it["teamid"], 0,
|
||||
"unknown club -> neutral 0, item still emitted"
|
||||
);
|
||||
assert_eq!(it["leagueId"], 13);
|
||||
assert_eq!(it["nation"], 0, "England not in the test nation map -> 0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn envelope_is_itemdata_object() {
|
||||
let ent = entities();
|
||||
let ident = MapIdentity(HashMap::new());
|
||||
let (body, _) = shape_club_response(&[], &ent, &ident);
|
||||
assert!(body.get("itemData").unwrap().is_array());
|
||||
assert_eq!(
|
||||
body.as_object().unwrap().len(),
|
||||
1,
|
||||
"only itemData at top level"
|
||||
);
|
||||
}
|
||||
|
||||
/// A resolver that resolves an asset id for EVERY item (so exclusion is not
|
||||
/// an artifact of a missing asset) but classifies some card_ids as non-player
|
||||
/// via an explicit kind table.
|
||||
struct KindMapIdentity {
|
||||
ids: HashMap<String, Fifa17Identity>,
|
||||
kinds: HashMap<String, ContentKind>,
|
||||
}
|
||||
impl ItemIdentityResolver for KindMapIdentity {
|
||||
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
self.ids.get(&it.card_id).copied()
|
||||
}
|
||||
fn kind_of(&self, it: &CoreOwnedItem) -> ContentKind {
|
||||
self.kinds
|
||||
.get(&it.card_id)
|
||||
.copied()
|
||||
.unwrap_or(ContentKind::Player)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_and_staff_are_excluded_from_club_players() {
|
||||
let ent = entities();
|
||||
let id = |item_id: u32, asset: u32| Fifa17Identity {
|
||||
item_id,
|
||||
asset_id: asset,
|
||||
resource_id: asset,
|
||||
rareflag: 1,
|
||||
};
|
||||
let ident = KindMapIdentity {
|
||||
ids: HashMap::from([
|
||||
("card_player".to_string(), id(100000001, 20801)),
|
||||
("card_consumable".to_string(), id(100000002, 5003012)),
|
||||
("card_staff".to_string(), id(100000003, 3000083)),
|
||||
]),
|
||||
kinds: HashMap::from([
|
||||
("card_consumable".to_string(), ContentKind::Consumable),
|
||||
("card_staff".to_string(), ContentKind::Staff),
|
||||
]),
|
||||
};
|
||||
let items = vec![
|
||||
item(
|
||||
"oc1",
|
||||
"card_player",
|
||||
86,
|
||||
"ST",
|
||||
"Argentina",
|
||||
"Premier League",
|
||||
"Chelsea",
|
||||
),
|
||||
item("oc2", "card_consumable", 0, "", "", "", ""),
|
||||
item("oc3", "card_staff", 0, "", "", "", ""),
|
||||
];
|
||||
let (body, stats) = shape_club_response(&items, &ent, &ident);
|
||||
assert_eq!(stats.emitted, 1, "only the player is emitted");
|
||||
assert_eq!(stats.excluded_non_player, 2, "consumable + staff excluded");
|
||||
assert_eq!(stats.dropped_no_asset, 0);
|
||||
let arr = body["itemData"].as_array().unwrap();
|
||||
assert_eq!(arr.len(), 1);
|
||||
assert_eq!(arr[0]["id"], 100000001, "the player survives");
|
||||
assert_eq!(arr[0]["itemType"], "player");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
//! FIFA17 MY CLUB stat set (`GET …/club/stats/{year,consumables}`), computed
|
||||
//! from OpenFUT Core's authoritative owned inventory.
|
||||
//!
|
||||
//! Faithful port of the Python oracle's `fut_club_stats.py` (`global_counts` +
|
||||
//! `context_rows` + `stats_body`), which is itself censused from CardsDLL
|
||||
//! (`FUN_18012fd40` atom table). The body is `{"stat":[{contextId,contextValue,
|
||||
//! type,typeValue}, …]}`:
|
||||
//! * a GLOBAL bucket (contextId 1, contextValue 0) with player tier counts,
|
||||
//! staff-by-family, consumables-by-family, and honest zeros for club items;
|
||||
//! * per-NATION buckets (contextId 3, contextValue = nation id) with the tier
|
||||
//! counts the MY CLUB summary panel sums into PLAYERS_EMPLOYED.
|
||||
//!
|
||||
//! Unlike the oracle (which counts its own stale profile + a synthetic consumable
|
||||
//! shelf), this counts Core — so staff and consumable families reflect the real
|
||||
//! imported content. Unrecognized atoms are inert in the client, so this is a
|
||||
//! low-risk cosmetic surface; the tier/staff/consumable atoms are the ones the
|
||||
//! screen reads and they are Core-accurate here.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::{consumable_family, ContentKind};
|
||||
|
||||
/// One owned item, already classified from the catalog + entity tables by the
|
||||
/// host. `subtype`/`rare` come from the FIFA catalog; `nation_id`/`league_id`/
|
||||
/// `team_id` from the reverse entity resolver (None = unresolved, bucket skipped).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ClubStatInput {
|
||||
pub kind: ContentKind,
|
||||
pub subtype: i64,
|
||||
pub rating: i64,
|
||||
pub rare: bool,
|
||||
pub nation_id: Option<i64>,
|
||||
pub league_id: Option<i64>,
|
||||
pub team_id: Option<i64>,
|
||||
}
|
||||
|
||||
/// Which entity the per-context (`contextId 3`) buckets are keyed by — the FIFA
|
||||
/// `MY CLUB` sub-screen selector (`fut_club_stats.py::context_rows`):
|
||||
/// * `Nation` — the default screen (year/consumables/club/newcards): nation buckets.
|
||||
/// * `League` — URL `club/stats/country/<id>`: league (leagueId) buckets, tier stats.
|
||||
/// * `Team` — URL `club/stats/league/<id>`: team (teamid) buckets, players/kits/badge.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ContextField {
|
||||
Nation,
|
||||
League,
|
||||
Team,
|
||||
}
|
||||
|
||||
// Stat ids (CardsDLL atom table, fut_club_stats.py VOCAB).
|
||||
const S_PLAYERS: i64 = 0x01;
|
||||
const S_BRONZE: i64 = 0x02;
|
||||
const S_SILVER: i64 = 0x03;
|
||||
const S_GOLD: i64 = 0x04;
|
||||
const S_RARE: i64 = 0x05;
|
||||
const S_STAFF: i64 = 0x0A;
|
||||
const S_CONSUMABLES: i64 = 0x3C;
|
||||
const S_KITS: i64 = 0x28;
|
||||
const S_BADGES: i64 = 0x2D;
|
||||
|
||||
/// cardsubtypeid (staff family) -> stat id (STAFF_SUBTYPE_STAT).
|
||||
fn staff_stat(subtype: i64) -> Option<i64> {
|
||||
match subtype {
|
||||
4 => Some(0x0B), // manager
|
||||
5 => Some(0x0C), // head coach
|
||||
6 => Some(0x0D), // GK coach
|
||||
7 => Some(0x0E), // physio
|
||||
8 => Some(0x0F), // fitness coach
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// consumable family `kind` -> stat id (CONSUMABLE_KIND_STAT).
|
||||
fn consumable_stat(kind: &str) -> Option<i64> {
|
||||
Some(match kind {
|
||||
"player_contract" => 0x42,
|
||||
"manager_contract" => 0x47,
|
||||
"healing" => 0x41,
|
||||
"player_fitness" => 0x44,
|
||||
"squad_fitness" => 0x4A,
|
||||
"gk_training" => 0x46,
|
||||
"player_training" => 0x43,
|
||||
"position_mod" => 0x45,
|
||||
"player_playstyle" => 0x4B,
|
||||
"gk_playstyle" => 0x4C,
|
||||
"manager_league" => 0x4D,
|
||||
"manager_formation_mod" | "formation_mod" => 0x48,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The JSON `type` atom name for a stat id (VOCAB). Only the ids this module
|
||||
/// emits are mapped; an unmapped id would panic (guards a transcription slip).
|
||||
fn vocab(stat_id: i64) -> &'static str {
|
||||
match stat_id {
|
||||
0x01 => "players",
|
||||
0x02 => "playersBronze",
|
||||
0x03 => "playersSilver",
|
||||
0x04 => "playersGold",
|
||||
0x05 => "rarePlayers",
|
||||
0x0A => "staff",
|
||||
0x0B => "staffManager",
|
||||
0x0C => "staffHeadCoach",
|
||||
0x0D => "staffGKCoach",
|
||||
0x0E => "staffPhysio",
|
||||
0x0F => "staffFitnessCoach",
|
||||
0x14 => "stadia",
|
||||
0x1E => "balls",
|
||||
0x28 => "kits",
|
||||
0x29 => "kitsHome",
|
||||
0x2A => "kitsAway",
|
||||
0x2D => "badges",
|
||||
0x2E => "badgeDBid",
|
||||
0x2F => "leagueLogos",
|
||||
0x32 => "trophies",
|
||||
0x33 => "trophiesOffline",
|
||||
0x34 => "trophiesOnline",
|
||||
0x35 => "trophiesFeaturedOffline",
|
||||
0x36 => "trophiesFeaturedOnline",
|
||||
0x37 => "trophiesSeasonOffline",
|
||||
0x38 => "trophiesSeasonOnline",
|
||||
0x3C => "consumables",
|
||||
0x41 => "consumablesHealing",
|
||||
0x42 => "consumablesContractPlayer",
|
||||
0x43 => "consumablesTrainingPlayer",
|
||||
0x44 => "consumablesFitnessPlayer",
|
||||
0x45 => "consumablesPosition",
|
||||
0x46 => "consumablesTrainingGk",
|
||||
0x47 => "consumablesContractManager",
|
||||
0x48 => "consumablesFormationManager",
|
||||
0x49 => "consumablesTrainingManager",
|
||||
0x4A => "consumablesFitnessTeam",
|
||||
0x4B => "consumablesTrainingPlayerPlayStyle",
|
||||
0x4C => "consumablesTrainingGkPlayStyle",
|
||||
0x4D => "consumablesTrainingManagerLeagueModifier",
|
||||
other => panic!("club_stats: unmapped stat id {other:#x}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn row(context_id: i64, context_value: i64, stat_id: i64, value: i64) -> Value {
|
||||
json!({
|
||||
"contextId": context_id,
|
||||
"contextValue": context_value,
|
||||
"type": vocab(stat_id),
|
||||
"typeValue": value,
|
||||
})
|
||||
}
|
||||
|
||||
fn is_player(i: &ClubStatInput) -> bool {
|
||||
matches!(i.kind, ContentKind::Player)
|
||||
}
|
||||
|
||||
/// Build the full `{"stat":[…]}` body for a club/stats screen — the global bucket
|
||||
/// (identical for every mode) plus per-context buckets keyed by `ctx`
|
||||
/// (nation / league / team), mirroring `fut_club_stats.py::stats_body`.
|
||||
pub fn club_stats_body(items: &[ClubStatInput], ctx: ContextField) -> Value {
|
||||
// ---- global bucket (contextId 1, contextValue 0), sorted by stat id ----
|
||||
let mut g: BTreeMap<i64, i64> = BTreeMap::new();
|
||||
let players: Vec<&ClubStatInput> = items.iter().filter(|i| is_player(i)).collect();
|
||||
g.insert(S_PLAYERS, players.len() as i64);
|
||||
g.insert(
|
||||
S_GOLD,
|
||||
players.iter().filter(|i| i.rating >= 75).count() as i64,
|
||||
);
|
||||
g.insert(
|
||||
S_SILVER,
|
||||
players
|
||||
.iter()
|
||||
.filter(|i| (65..75).contains(&i.rating))
|
||||
.count() as i64,
|
||||
);
|
||||
g.insert(
|
||||
S_BRONZE,
|
||||
players
|
||||
.iter()
|
||||
.filter(|i| i.rating > 0 && i.rating < 65)
|
||||
.count() as i64,
|
||||
);
|
||||
g.insert(S_RARE, players.iter().filter(|i| i.rare).count() as i64);
|
||||
|
||||
// staff per family + total
|
||||
for sid in [0x0B, 0x0C, 0x0D, 0x0E, 0x0F] {
|
||||
g.insert(sid, 0);
|
||||
}
|
||||
let mut staff_total = 0i64;
|
||||
for it in items
|
||||
.iter()
|
||||
.filter(|i| matches!(i.kind, ContentKind::Staff))
|
||||
{
|
||||
if let Some(sid) = staff_stat(it.subtype) {
|
||||
*g.get_mut(&sid).unwrap() += 1;
|
||||
staff_total += 1;
|
||||
}
|
||||
}
|
||||
g.insert(S_STAFF, staff_total);
|
||||
|
||||
// consumables per family + total
|
||||
for sid in [
|
||||
0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x4B, 0x4C, 0x4D,
|
||||
] {
|
||||
g.insert(sid, 0);
|
||||
}
|
||||
let mut cons_total = 0i64;
|
||||
for it in items
|
||||
.iter()
|
||||
.filter(|i| matches!(i.kind, ContentKind::Consumable))
|
||||
{
|
||||
cons_total += 1;
|
||||
if let Some((kind, _label)) = consumable_family(it.subtype) {
|
||||
if let Some(sid) = consumable_stat(kind) {
|
||||
*g.entry(sid).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
g.insert(S_CONSUMABLES, cons_total);
|
||||
|
||||
// club items: honest zeros (Core holds none; each is read by some panel).
|
||||
for sid in [
|
||||
0x14, 0x1E, 0x28, 0x29, 0x2A, 0x2D, 0x2E, 0x2F, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38,
|
||||
] {
|
||||
g.entry(sid).or_insert(0);
|
||||
}
|
||||
|
||||
let mut stat: Vec<Value> = g.iter().map(|(sid, v)| row(1, 0, *sid, *v)).collect();
|
||||
|
||||
// ---- per-context buckets (contextId 3, contextValue = entity id) ----
|
||||
// Nation/League read the tier set (gold/silver/bronze/rare/kits/badges);
|
||||
// Team (the league screen) reads players/kits/badgeDBid. Mirrors context_rows.
|
||||
let mut by_ctx: BTreeMap<i64, Vec<&ClubStatInput>> = BTreeMap::new();
|
||||
for p in &players {
|
||||
let id = match ctx {
|
||||
ContextField::Nation => p.nation_id,
|
||||
ContextField::League => p.league_id,
|
||||
ContextField::Team => p.team_id,
|
||||
};
|
||||
if let Some(id) = id {
|
||||
by_ctx.entry(id).or_default().push(p);
|
||||
}
|
||||
}
|
||||
for (cid, sel) in &by_ctx {
|
||||
if ctx == ContextField::Team {
|
||||
stat.push(row(3, *cid, S_PLAYERS, sel.len() as i64));
|
||||
stat.push(row(3, *cid, S_KITS, 0));
|
||||
stat.push(row(3, *cid, 0x2E, 0)); // badgeDBid
|
||||
} else {
|
||||
let gold = sel.iter().filter(|i| i.rating >= 75).count() as i64;
|
||||
let silver = sel.iter().filter(|i| (65..75).contains(&i.rating)).count() as i64;
|
||||
let bronze = sel.iter().filter(|i| i.rating > 0 && i.rating < 65).count() as i64;
|
||||
let rare = sel.iter().filter(|i| i.rare).count() as i64;
|
||||
stat.push(row(3, *cid, S_GOLD, gold));
|
||||
stat.push(row(3, *cid, S_SILVER, silver));
|
||||
stat.push(row(3, *cid, S_BRONZE, bronze));
|
||||
stat.push(row(3, *cid, S_RARE, rare));
|
||||
stat.push(row(3, *cid, S_KITS, 0));
|
||||
stat.push(row(3, *cid, S_BADGES, 0));
|
||||
}
|
||||
}
|
||||
|
||||
json!({ "stat": stat })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn player(rating: i64, rare: bool, nation: Option<i64>) -> ClubStatInput {
|
||||
ClubStatInput {
|
||||
kind: ContentKind::Player,
|
||||
subtype: 0,
|
||||
rating,
|
||||
rare,
|
||||
nation_id: nation,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
}
|
||||
}
|
||||
fn staff(subtype: i64) -> ClubStatInput {
|
||||
ClubStatInput {
|
||||
kind: ContentKind::Staff,
|
||||
subtype,
|
||||
rating: 0,
|
||||
rare: false,
|
||||
nation_id: None,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
}
|
||||
}
|
||||
fn consumable(subtype: i64) -> ClubStatInput {
|
||||
ClubStatInput {
|
||||
kind: ContentKind::Consumable,
|
||||
subtype,
|
||||
rating: 0,
|
||||
rare: false,
|
||||
nation_id: None,
|
||||
league_id: None,
|
||||
team_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn global(body: &Value) -> std::collections::HashMap<String, i64> {
|
||||
body["stat"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r["contextId"] == 1)
|
||||
.map(|r| {
|
||||
(
|
||||
r["type"].as_str().unwrap().to_string(),
|
||||
r["typeValue"].as_i64().unwrap(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiers_and_rare_counted() {
|
||||
let items = vec![
|
||||
player(90, true, Some(52)),
|
||||
player(70, true, Some(52)),
|
||||
player(60, false, Some(21)),
|
||||
];
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["players"], 3);
|
||||
assert_eq!(g["playersGold"], 1);
|
||||
assert_eq!(g["playersSilver"], 1);
|
||||
assert_eq!(g["playersBronze"], 1);
|
||||
assert_eq!(g["rarePlayers"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staff_by_family() {
|
||||
let items = vec![staff(6), staff(8), staff(8)]; // 1 gk coach, 2 fitness
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["staffGKCoach"], 1);
|
||||
assert_eq!(g["staffFitnessCoach"], 2);
|
||||
assert_eq!(g["staff"], 3);
|
||||
assert_eq!(g["staffManager"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumables_by_family() {
|
||||
// 54 gk_training, 201 player_contract, 217 healing, 258 player_playstyle
|
||||
let items = vec![
|
||||
consumable(54),
|
||||
consumable(201),
|
||||
consumable(217),
|
||||
consumable(258),
|
||||
];
|
||||
let g = global(&club_stats_body(&items, ContextField::Nation));
|
||||
assert_eq!(g["consumables"], 4);
|
||||
assert_eq!(g["consumablesTrainingGk"], 1);
|
||||
assert_eq!(g["consumablesContractPlayer"], 1);
|
||||
assert_eq!(g["consumablesHealing"], 1);
|
||||
assert_eq!(g["consumablesTrainingPlayerPlayStyle"], 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nation_buckets_emitted_and_players_excludes_nonplayers() {
|
||||
let items = vec![
|
||||
player(90, true, Some(52)),
|
||||
player(80, false, Some(52)),
|
||||
staff(8),
|
||||
];
|
||||
let body = club_stats_body(&items, ContextField::Nation);
|
||||
let g = global(&body);
|
||||
assert_eq!(g["players"], 2, "staff not counted as player");
|
||||
let buckets: Vec<&Value> = body["stat"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r["contextId"] == 3 && r["contextValue"] == 52)
|
||||
.collect();
|
||||
// gold, silver, bronze, rare, kits, badges
|
||||
assert_eq!(buckets.len(), 6);
|
||||
let gold = buckets.iter().find(|r| r["type"] == "playersGold").unwrap();
|
||||
assert_eq!(gold["typeValue"], 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn honest_zero_club_items_present() {
|
||||
let g = global(&club_stats_body(&[player(90, false, None)], ContextField::Nation));
|
||||
for atom in [
|
||||
"stadia",
|
||||
"balls",
|
||||
"kits",
|
||||
"badges",
|
||||
"trophies",
|
||||
"leagueLogos",
|
||||
] {
|
||||
assert_eq!(g[atom], 0, "{atom} present as honest zero");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn league_and_team_context_modes() {
|
||||
let mut a = player(90, true, Some(52));
|
||||
a.league_id = Some(13);
|
||||
a.team_id = Some(240);
|
||||
let mut b = player(60, false, Some(52));
|
||||
b.league_id = Some(13);
|
||||
b.team_id = Some(9);
|
||||
let items = vec![a, b];
|
||||
|
||||
// country screen -> league (leagueId) buckets, tier set (6 rows).
|
||||
let body = club_stats_body(&items, ContextField::League);
|
||||
let league_rows: Vec<&Value> = body["stat"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r["contextId"] == 3 && r["contextValue"] == 13)
|
||||
.collect();
|
||||
assert_eq!(league_rows.len(), 6);
|
||||
let gold = league_rows.iter().find(|r| r["type"] == "playersGold").unwrap();
|
||||
assert_eq!(gold["typeValue"], 1);
|
||||
|
||||
// league screen -> team (teamid) buckets: players/kits/badgeDBid (3 rows).
|
||||
let body = club_stats_body(&items, ContextField::Team);
|
||||
let team_rows: Vec<&Value> = body["stat"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|r| r["contextId"] == 3 && r["contextValue"] == 240)
|
||||
.collect();
|
||||
assert_eq!(team_rows.len(), 3);
|
||||
let players = team_rows.iter().find(|r| r["type"] == "players").unwrap();
|
||||
assert_eq!(players["typeValue"], 1);
|
||||
assert!(team_rows.iter().any(|r| r["type"] == "badgeDBid"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
//! FIFA 17 **non-player content taxonomy** — the evidence-based map from a card
|
||||
//! `cardsubtypeid` to its functional family (consumables) or role (staff).
|
||||
//!
|
||||
//! This is the ONLY place the FIFA-specific `cardsubtypeid` vocabulary lives; it
|
||||
//! keeps that game concept out of generic Core, exactly as the player-side
|
||||
//! catalog keeps `resourceId`/`rareflag` out of Core. Nothing here is guessed:
|
||||
//!
|
||||
//! * Consumable families and their contiguous `cardsubtypeid` ranges are taken
|
||||
//! verbatim from `fifa17-recon/tools/fut_consumables.py`
|
||||
//! (`BY_SUBTYPE`/`CORE_KINDS`, Ghidra-derived from `FUN_18013f4d0` /
|
||||
//! `FUN_1801bfac0`) and `docs/CARD_TAXONOMY.md` (verified against the `.105`
|
||||
//! `fcc_*.json` tables).
|
||||
//! * Staff roles are the `FUN_1800d8330` family selector: 4=manager, 5=headcoach,
|
||||
//! 6=gkcoach, 7=physio, 8=fitnesscoach.
|
||||
//!
|
||||
//! Display **labels are functional, never marketing** (e.g. "Player Chemistry
|
||||
//! Style", not a promo name). A `cardsubtypeid` outside every documented range
|
||||
//! resolves to `None` — the caller DEFERS it (mirroring the player NoName gate),
|
||||
//! never fabricating a family.
|
||||
|
||||
/// The disjoint content classes a FIFA 17 owned card can belong to. Player is
|
||||
/// the default so a catalog authored before this taxonomy existed (no `kind`
|
||||
/// field) still classifies every entry as a player, unchanged.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum ContentKind {
|
||||
#[default]
|
||||
Player,
|
||||
Consumable,
|
||||
Staff,
|
||||
}
|
||||
|
||||
impl ContentKind {
|
||||
/// The stable wire/catalog token for this kind.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
ContentKind::Player => "player",
|
||||
ContentKind::Consumable => "consumable",
|
||||
ContentKind::Staff => "staff",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a catalog `kind` token. Unknown or "player" (or an absent field that
|
||||
/// deserializes to the default) is `Player` — backward compatible.
|
||||
// Intentionally infallible (every input maps to a kind, unknown → Player), so
|
||||
// it is NOT `std::str::FromStr` (which is fallible); the name mirrors the
|
||||
// catalog token vocabulary.
|
||||
#[allow(clippy::should_implement_trait)]
|
||||
pub fn from_str(s: &str) -> ContentKind {
|
||||
match s {
|
||||
"consumable" => ContentKind::Consumable,
|
||||
"staff" => ContentKind::Staff,
|
||||
_ => ContentKind::Player,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The functional family + honest display label for a consumable `cardsubtypeid`,
|
||||
/// or `None` if the subtype is outside every documented range (→ DEFER).
|
||||
///
|
||||
/// Returns `(family, label)`, both `'static`. `family` is the neutral machine
|
||||
/// name stored as the CardDefinition family; `label` is the functional
|
||||
/// human-readable name.
|
||||
pub fn consumable_family(subtype: i64) -> Option<(&'static str, &'static str)> {
|
||||
let pair = match subtype {
|
||||
51..=57 => ("gk_training", "GK Training"),
|
||||
61..=67 => ("player_training", "Player Training"),
|
||||
71..=86 => ("manager_formation_mod", "Manager Formation"),
|
||||
91..=110 => ("position_mod", "Position Modifier"),
|
||||
121..=136 => ("formation_mod", "Formation Modifier"),
|
||||
201 => ("player_contract", "Player Contract"),
|
||||
202 => ("manager_contract", "Manager Contract"),
|
||||
211..=218 => ("healing", "Healing"),
|
||||
219 => ("player_fitness", "Player Fitness"),
|
||||
220 => ("squad_fitness", "Squad Fitness"),
|
||||
250..=268 => ("player_playstyle", "Player Chemistry Style"),
|
||||
269..=273 => ("gk_playstyle", "GK Chemistry Style"),
|
||||
300..=341 => ("manager_league", "Manager League Modifier"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(pair)
|
||||
}
|
||||
|
||||
/// The staff role + honest display label for a staff `cardsubtypeid` (4..=8), or
|
||||
/// `None` for any other subtype (→ DEFER). Grounded in the `FUN_1800d8330`
|
||||
/// family selector.
|
||||
pub fn staff_role(subtype: i64) -> Option<(&'static str, &'static str)> {
|
||||
let pair = match subtype {
|
||||
4 => ("manager", "Manager"),
|
||||
5 => ("headcoach", "Head Coach"),
|
||||
6 => ("gkcoach", "GK Coach"),
|
||||
7 => ("physio", "Physio"),
|
||||
8 => ("fitnesscoach", "Fitness Coach"),
|
||||
_ => return None,
|
||||
};
|
||||
Some(pair)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn content_kind_round_trips_and_defaults_to_player() {
|
||||
assert_eq!(ContentKind::default(), ContentKind::Player);
|
||||
for k in [
|
||||
ContentKind::Player,
|
||||
ContentKind::Consumable,
|
||||
ContentKind::Staff,
|
||||
] {
|
||||
assert_eq!(ContentKind::from_str(k.as_str()), k);
|
||||
}
|
||||
// Unknown / absent tokens fall back to Player (backward compatible).
|
||||
assert_eq!(ContentKind::from_str(""), ContentKind::Player);
|
||||
assert_eq!(ContentKind::from_str("nonsense"), ContentKind::Player);
|
||||
assert_eq!(ContentKind::from_str("player"), ContentKind::Player);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_family_range_boundaries() {
|
||||
// Each contiguous range: lower boundary, upper boundary, family + label.
|
||||
let cases: &[(i64, i64, &str, &str)] = &[
|
||||
(51, 57, "gk_training", "GK Training"),
|
||||
(61, 67, "player_training", "Player Training"),
|
||||
(71, 86, "manager_formation_mod", "Manager Formation"),
|
||||
(91, 110, "position_mod", "Position Modifier"),
|
||||
(121, 136, "formation_mod", "Formation Modifier"),
|
||||
(211, 218, "healing", "Healing"),
|
||||
(250, 268, "player_playstyle", "Player Chemistry Style"),
|
||||
(269, 273, "gk_playstyle", "GK Chemistry Style"),
|
||||
(300, 341, "manager_league", "Manager League Modifier"),
|
||||
];
|
||||
for &(lo, hi, family, label) in cases {
|
||||
assert_eq!(consumable_family(lo), Some((family, label)), "lo {lo}");
|
||||
assert_eq!(consumable_family(hi), Some((family, label)), "hi {hi}");
|
||||
}
|
||||
// Singleton subtypes.
|
||||
assert_eq!(
|
||||
consumable_family(201),
|
||||
Some(("player_contract", "Player Contract"))
|
||||
);
|
||||
assert_eq!(
|
||||
consumable_family(202),
|
||||
Some(("manager_contract", "Manager Contract"))
|
||||
);
|
||||
assert_eq!(
|
||||
consumable_family(219),
|
||||
Some(("player_fitness", "Player Fitness"))
|
||||
);
|
||||
assert_eq!(
|
||||
consumable_family(220),
|
||||
Some(("squad_fitness", "Squad Fitness"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consumable_family_gaps_and_out_of_range_are_none() {
|
||||
// Just outside range edges, and in documented gaps between ranges.
|
||||
for s in [
|
||||
0, 50, 58, 60, 68, 70, 87, 90, 111, 120, 137, 200, 203, 210, 221, 249, 274, 299, 342,
|
||||
999,
|
||||
] {
|
||||
assert_eq!(consumable_family(s), None, "subtype {s} must be unknown");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staff_role_each_role_and_unknown_is_none() {
|
||||
assert_eq!(staff_role(4), Some(("manager", "Manager")));
|
||||
assert_eq!(staff_role(5), Some(("headcoach", "Head Coach")));
|
||||
assert_eq!(staff_role(6), Some(("gkcoach", "GK Coach")));
|
||||
assert_eq!(staff_role(7), Some(("physio", "Physio")));
|
||||
assert_eq!(staff_role(8), Some(("fitnesscoach", "Fitness Coach")));
|
||||
for s in [0, 1, 2, 3, 9, 10, 201, 300] {
|
||||
assert_eq!(staff_role(s), None, "staff subtype {s} must be unknown");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,442 @@
|
||||
//! FIFA 17 authoritative economy engine — coins + unopened-pack entitlements +
|
||||
//! owned inventory + stable item ids — with all-or-nothing transactional mutations.
|
||||
//!
|
||||
//! A faithful port of the Python oracle's `fut_store.Store` mutation primitives
|
||||
//! (`spend`/`grant_coins`/`quick_sell`/`record_match`/`grant_unopened_pack`/
|
||||
//! `consume_unopened_pack`/`open_pack`/`add_items`) — the single-writer engine the
|
||||
//! eventual economy cutover needs.
|
||||
//!
|
||||
//! ## Status / why not wired (R3)
|
||||
//!
|
||||
//! This engine is deliberately **not wired** into the live host. The live FIFA 17
|
||||
//! coin balance is one indivisible writer set — Store BUY (`spend`), pack-open,
|
||||
//! quick-sell, match rewards (`record_match`) AND the transfer-market buy-now
|
||||
//! (`spend`) all mutate the same `coins` + inventory in Python's `fut_profile.json`.
|
||||
//! No single route can become Rust-authoritative without migrating the whole
|
||||
//! cluster at once. The intended generic home (OpenFUT Core) is a preserved-dirty,
|
||||
//! frozen submodule, so the generic currency/inventory/transaction primitives can't
|
||||
//! land there yet. This engine + importer is therefore the coherent prerequisite:
|
||||
//! wiring it (and migrating every coin/inventory writer in one cut) is the R1 task.
|
||||
//!
|
||||
//! Generic concepts (balance/inventory/transaction) belong in Core once unfrozen;
|
||||
//! they are kept in the adapter meanwhile without distorting Core.
|
||||
//!
|
||||
//! ## Economy-parameter provenance
|
||||
//!
|
||||
//! Prices/odds/quick-sell values are current OpenFUT **PLACEHOLDER** economy, not
|
||||
//! EA-authentic. The mutation *invariants* (atomic debit, consume-once, no partial
|
||||
//! state, sentinel non-grantable) are the load-bearing contract this engine enforces.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::store_catalog::pack_by_id;
|
||||
use crate::fut::store_session::SENTINEL_PACK_ID;
|
||||
|
||||
/// A transactional failure. On any `Err`, the engine is left UNCHANGED (no partial
|
||||
/// mutation) — the caller may retry or surface a wire error.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EconomyError {
|
||||
/// Debit rejected: balance would go negative.
|
||||
InsufficientFunds { balance: i64, needed: i64 },
|
||||
/// Pack id is not in the catalogue (includes the 65534 sentinel).
|
||||
UnknownPack(u64),
|
||||
/// No owned instance of this pack to consume.
|
||||
NotOwned(u64),
|
||||
/// The 65534 sentinel can never be bought/granted/opened.
|
||||
SentinelRejected,
|
||||
}
|
||||
|
||||
/// The authoritative per-profile economy state. Mirrors the load-bearing
|
||||
/// `fut_profile.json` fields. Wrap in a profile-scoped `Mutex`/DB transaction at the
|
||||
/// host boundary (as the eventual Core repository will); the methods here are the
|
||||
/// atomic units.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ProfileEconomy {
|
||||
coins: i64,
|
||||
unopened_pack_ids: Vec<u64>,
|
||||
items: Vec<Value>,
|
||||
next_item_id: i64,
|
||||
}
|
||||
|
||||
impl ProfileEconomy {
|
||||
/// A fresh, empty economy (coins 0, no packs/items, ids from 1).
|
||||
pub fn new() -> Self {
|
||||
ProfileEconomy {
|
||||
coins: 0,
|
||||
unopened_pack_ids: Vec::new(),
|
||||
items: Vec::new(),
|
||||
next_item_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// Import from a `fut_profile.json` object (the current authoritative store).
|
||||
/// Deterministic and idempotent on a fixture: reads coins, `unopenedPackIds`,
|
||||
/// `items`, `nextItemId`; missing fields take safe defaults. Never mutates the
|
||||
/// source. Production migration is NOT executed here.
|
||||
pub fn from_fut_profile(profile: &Value) -> Self {
|
||||
let coins = profile.get("coins").and_then(Value::as_i64).unwrap_or(0);
|
||||
let unopened_pack_ids = profile
|
||||
.get("unopenedPackIds")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().filter_map(Value::as_u64).collect())
|
||||
.unwrap_or_default();
|
||||
let items = profile
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
// Continue item-id allocation past the highest existing id so re-import can
|
||||
// never mint a duplicate (Python persists nextItemId; we also floor by it).
|
||||
let max_item_id = items
|
||||
.iter()
|
||||
.filter_map(|it| it.get("id").and_then(Value::as_i64))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let next_item_id = profile
|
||||
.get("nextItemId")
|
||||
.and_then(Value::as_i64)
|
||||
.unwrap_or(1)
|
||||
.max(max_item_id + 1);
|
||||
ProfileEconomy {
|
||||
coins,
|
||||
unopened_pack_ids,
|
||||
items,
|
||||
next_item_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write this economy back onto a `fut_profile.json` object (round-trip / export).
|
||||
/// Only the economy fields are touched; every other key is preserved.
|
||||
pub fn apply_to_fut_profile(&self, profile: &mut Value) {
|
||||
let obj = profile
|
||||
.as_object_mut()
|
||||
.expect("fut_profile is a JSON object");
|
||||
obj.insert("coins".into(), json!(self.coins));
|
||||
obj.insert("unopenedPackIds".into(), json!(self.unopened_pack_ids));
|
||||
obj.insert("items".into(), Value::Array(self.items.clone()));
|
||||
obj.insert("nextItemId".into(), json!(self.next_item_id));
|
||||
}
|
||||
|
||||
// ── reads ────────────────────────────────────────────────────────────────
|
||||
pub fn coins(&self) -> i64 {
|
||||
self.coins
|
||||
}
|
||||
pub fn unopened_pack_ids(&self) -> &[u64] {
|
||||
&self.unopened_pack_ids
|
||||
}
|
||||
pub fn next_item_id(&self) -> i64 {
|
||||
self.next_item_id
|
||||
}
|
||||
pub fn item_ids(&self) -> Vec<i64> {
|
||||
self.items
|
||||
.iter()
|
||||
.filter_map(|it| it.get("id").and_then(Value::as_i64))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── generic primitives (atomic building blocks) ───────────────────────────
|
||||
|
||||
/// Credit coins (reward/quick-sell proceeds). Non-negative by type.
|
||||
pub fn credit(&mut self, amount: u64) {
|
||||
self.coins += amount as i64;
|
||||
}
|
||||
|
||||
/// Debit coins. Fail-closed: insufficient balance leaves coins UNCHANGED.
|
||||
pub fn debit(&mut self, amount: u64) -> Result<(), EconomyError> {
|
||||
let needed = amount as i64;
|
||||
if self.coins < needed {
|
||||
return Err(EconomyError::InsufficientFunds {
|
||||
balance: self.coins,
|
||||
needed,
|
||||
});
|
||||
}
|
||||
self.coins -= needed;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Grant one owned instance of a catalogue pack (reward/purchase entitlement).
|
||||
/// Rejects the 65534 sentinel and any non-catalogue id (mirrors
|
||||
/// `grant_unopened_pack`, which returns False for `pack_by_id() is None`).
|
||||
pub fn grant_pack(&mut self, pack_id: u64) -> Result<(), EconomyError> {
|
||||
if pack_id == SENTINEL_PACK_ID {
|
||||
return Err(EconomyError::SentinelRejected);
|
||||
}
|
||||
if pack_by_id(pack_id).is_none() {
|
||||
return Err(EconomyError::UnknownPack(pack_id));
|
||||
}
|
||||
self.unopened_pack_ids.push(pack_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Consume exactly one owned instance of a pack. Fail-closed: not owned ⇒ Err,
|
||||
/// no mutation (consume-once; mirrors `consume_unopened_pack`).
|
||||
pub fn consume_pack(&mut self, pack_id: u64) -> Result<(), EconomyError> {
|
||||
match self.unopened_pack_ids.iter().position(|&p| p == pack_id) {
|
||||
Some(idx) => {
|
||||
self.unopened_pack_ids.remove(idx);
|
||||
Ok(())
|
||||
}
|
||||
None => Err(EconomyError::NotOwned(pack_id)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Allocate the next stable, monotonic, unique item id.
|
||||
pub fn allocate_item_id(&mut self) -> i64 {
|
||||
let id = self.next_item_id;
|
||||
self.next_item_id += 1;
|
||||
id
|
||||
}
|
||||
|
||||
/// Add an owned item, stamping a fresh unique id (overwriting any incoming id),
|
||||
/// and return the assigned id.
|
||||
pub fn add_item(&mut self, mut item: Value) -> i64 {
|
||||
let id = self.allocate_item_id();
|
||||
if let Some(obj) = item.as_object_mut() {
|
||||
obj.insert("id".into(), json!(id));
|
||||
}
|
||||
self.items.push(item);
|
||||
id
|
||||
}
|
||||
|
||||
// ── composed atomic transactions (validate-then-mutate) ────────────────────
|
||||
|
||||
/// Store BUY as an entitlement: validate the pack + funds FIRST, then debit and
|
||||
/// grant the unopened pack — all-or-nothing. Rejects the sentinel. (The Python
|
||||
/// oracle opens on buy; the authoritative model separates buy→entitlement→open,
|
||||
/// which is why `open_pack` exists — a DIFFERENT-BY-DESIGN improvement over the
|
||||
/// reference, preserving the coin/entitlement invariants.)
|
||||
pub fn buy_pack(&mut self, pack_id: u64, price: u64) -> Result<(), EconomyError> {
|
||||
if pack_id == SENTINEL_PACK_ID {
|
||||
return Err(EconomyError::SentinelRejected);
|
||||
}
|
||||
if pack_by_id(pack_id).is_none() {
|
||||
return Err(EconomyError::UnknownPack(pack_id));
|
||||
}
|
||||
if self.coins < price as i64 {
|
||||
return Err(EconomyError::InsufficientFunds {
|
||||
balance: self.coins,
|
||||
needed: price as i64,
|
||||
});
|
||||
}
|
||||
// Both preconditions hold: commit.
|
||||
self.coins -= price as i64;
|
||||
self.unopened_pack_ids.push(pack_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Open an owned pack: consume exactly one entitlement FIRST (so a failed/absent
|
||||
/// entitlement grants nothing), then add the generated items with fresh ids.
|
||||
/// Returns the ids granted. Content generation/odds are the caller's concern and
|
||||
/// are current OpenFUT PLACEHOLDER.
|
||||
pub fn open_pack(&mut self, pack_id: u64, items: Vec<Value>) -> Result<Vec<i64>, EconomyError> {
|
||||
self.consume_pack(pack_id)?; // fail-closed: no items on a missing entitlement
|
||||
Ok(items.into_iter().map(|it| self.add_item(it)).collect())
|
||||
}
|
||||
|
||||
/// Quick-sell owned items by id: remove them and credit the caller-computed value
|
||||
/// total (values are FIFA17 policy, placeholder). Only ids actually owned are
|
||||
/// sold/credited (mirrors `quick_sell`). Returns `(sold_count, coins_credited)`.
|
||||
pub fn quick_sell(&mut self, ids: &[i64], value_of: impl Fn(&Value) -> u64) -> (u64, u64) {
|
||||
let want: std::collections::HashSet<i64> = ids.iter().copied().collect();
|
||||
let mut credited = 0u64;
|
||||
let mut sold = 0u64;
|
||||
let mut kept = Vec::with_capacity(self.items.len());
|
||||
for it in std::mem::take(&mut self.items) {
|
||||
let owned_id = it.get("id").and_then(Value::as_i64);
|
||||
if owned_id.is_some_and(|id| want.contains(&id)) {
|
||||
credited += value_of(&it);
|
||||
sold += 1;
|
||||
} else {
|
||||
kept.push(it);
|
||||
}
|
||||
}
|
||||
self.items = kept;
|
||||
if sold > 0 {
|
||||
self.credit(credited);
|
||||
}
|
||||
(sold, credited)
|
||||
}
|
||||
|
||||
/// Transfer-market buy-now: debit the price FIRST, then acquire the item — the
|
||||
/// market shares the SAME authoritative coin balance (that is why it is inside
|
||||
/// this engine's boundary). All-or-nothing.
|
||||
pub fn market_buy_now(&mut self, price: u64, item: Value) -> Result<i64, EconomyError> {
|
||||
self.debit(price)?;
|
||||
Ok(self.add_item(item))
|
||||
}
|
||||
|
||||
/// Match/reward coin credit (`record_match` coin part; SBC/objective grants).
|
||||
pub fn grant_reward(&mut self, coins: u64) {
|
||||
self.credit(coins);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProfileEconomy {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn eco(coins: i64, unopened: &[u64]) -> ProfileEconomy {
|
||||
ProfileEconomy {
|
||||
coins,
|
||||
unopened_pack_ids: unopened.to_vec(),
|
||||
items: Vec::new(),
|
||||
next_item_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debit_insufficient_is_fail_closed() {
|
||||
let mut e = eco(100, &[]);
|
||||
assert_eq!(
|
||||
e.debit(101),
|
||||
Err(EconomyError::InsufficientFunds {
|
||||
balance: 100,
|
||||
needed: 101
|
||||
})
|
||||
);
|
||||
assert_eq!(e.coins(), 100, "no mutation on failure");
|
||||
assert_eq!(e.debit(100), Ok(()));
|
||||
assert_eq!(e.coins(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn buy_pack_is_atomic() {
|
||||
// Insufficient funds: neither coins nor entitlements change.
|
||||
let mut poor = eco(399, &[]);
|
||||
assert!(matches!(
|
||||
poor.buy_pack(1, 400),
|
||||
Err(EconomyError::InsufficientFunds { .. })
|
||||
));
|
||||
assert_eq!(poor.coins(), 399);
|
||||
assert!(poor.unopened_pack_ids().is_empty());
|
||||
// Enough funds: debit + grant together.
|
||||
let mut ok = eco(1000, &[]);
|
||||
assert_eq!(ok.buy_pack(1, 400), Ok(()));
|
||||
assert_eq!(ok.coins(), 600);
|
||||
assert_eq!(ok.unopened_pack_ids(), &[1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentinel_can_never_be_bought_or_granted() {
|
||||
let mut e = eco(1_000_000, &[]);
|
||||
assert_eq!(
|
||||
e.buy_pack(SENTINEL_PACK_ID, 0),
|
||||
Err(EconomyError::SentinelRejected)
|
||||
);
|
||||
assert_eq!(
|
||||
e.grant_pack(SENTINEL_PACK_ID),
|
||||
Err(EconomyError::SentinelRejected)
|
||||
);
|
||||
// Never openable either (no entitlement can exist for it).
|
||||
assert_eq!(
|
||||
e.open_pack(SENTINEL_PACK_ID, vec![json!({})]),
|
||||
Err(EconomyError::NotOwned(SENTINEL_PACK_ID))
|
||||
);
|
||||
assert_eq!(e.coins(), 1_000_000, "sentinel ops never mutate economy");
|
||||
assert!(e.item_ids().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_pack_rejected() {
|
||||
let mut e = eco(1_000_000, &[]);
|
||||
assert_eq!(e.buy_pack(999, 0), Err(EconomyError::UnknownPack(999)));
|
||||
assert_eq!(e.grant_pack(999), Err(EconomyError::UnknownPack(999)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_pack_consumes_exactly_once() {
|
||||
let mut e = eco(0, &[70]);
|
||||
let granted = e.open_pack(70, vec![json!({"rating": 84}), json!({"rating": 90})]);
|
||||
assert_eq!(granted, Ok(vec![1, 2]));
|
||||
assert!(e.unopened_pack_ids().is_empty(), "entitlement consumed");
|
||||
assert_eq!(e.item_ids(), vec![1, 2], "unique ids assigned");
|
||||
// Second open of the same (now-absent) entitlement grants nothing.
|
||||
assert_eq!(
|
||||
e.open_pack(70, vec![json!({})]),
|
||||
Err(EconomyError::NotOwned(70))
|
||||
);
|
||||
assert_eq!(e.item_ids(), vec![1, 2], "no items added on failed open");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_sell_removes_owned_and_credits() {
|
||||
let mut e = eco(100, &[]);
|
||||
let a = e.add_item(json!({"rating": 84}));
|
||||
let b = e.add_item(json!({"rating": 90}));
|
||||
// sell only `a`; an unknown id is ignored (not owned).
|
||||
let (sold, credited) = e.quick_sell(&[a, 99999], |_| 300);
|
||||
assert_eq!((sold, credited), (1, 300));
|
||||
assert_eq!(e.coins(), 400);
|
||||
assert_eq!(e.item_ids(), vec![b], "only the sold item removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn market_buy_now_is_atomic() {
|
||||
let mut poor = eco(50, &[]);
|
||||
assert!(matches!(
|
||||
poor.market_buy_now(100, json!({"rating": 84})),
|
||||
Err(EconomyError::InsufficientFunds { .. })
|
||||
));
|
||||
assert_eq!(poor.coins(), 50);
|
||||
assert!(poor.item_ids().is_empty(), "no item acquired on failed buy");
|
||||
let mut ok = eco(500, &[]);
|
||||
let id = ok.market_buy_now(100, json!({"rating": 84})).unwrap();
|
||||
assert_eq!(ok.coins(), 400);
|
||||
assert_eq!(ok.item_ids(), vec![id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_ids_are_unique_and_monotonic() {
|
||||
let mut e = ProfileEconomy::new();
|
||||
let ids: Vec<i64> = (0..5).map(|_| e.allocate_item_id()).collect();
|
||||
assert_eq!(ids, vec![1, 2, 3, 4, 5]);
|
||||
assert_eq!(e.next_item_id(), 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fut_profile_import_export_round_trip() {
|
||||
let mut profile = json!({
|
||||
"personaId": 33068179,
|
||||
"coins": 29876776,
|
||||
"unopenedPackIds": [70],
|
||||
"items": [{"id": 41, "rating": 84}, {"id": 42, "rating": 90}],
|
||||
"nextItemId": 43,
|
||||
"clubName": "OpenFUT"
|
||||
});
|
||||
let e = ProfileEconomy::from_fut_profile(&profile);
|
||||
assert_eq!(e.coins(), 29876776);
|
||||
assert_eq!(e.unopened_pack_ids(), &[70]);
|
||||
assert_eq!(e.next_item_id(), 43, "past the highest existing id");
|
||||
// Export preserves unrelated keys and reflects the economy exactly.
|
||||
e.apply_to_fut_profile(&mut profile);
|
||||
assert_eq!(
|
||||
profile["clubName"],
|
||||
json!("OpenFUT"),
|
||||
"unrelated key preserved"
|
||||
);
|
||||
assert_eq!(profile["coins"], json!(29876776));
|
||||
assert_eq!(profile["nextItemId"], json!(43));
|
||||
// Re-import is stable.
|
||||
let e2 = ProfileEconomy::from_fut_profile(&profile);
|
||||
assert_eq!(e, e2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_floors_next_item_id_past_existing_ids() {
|
||||
// A stale/low nextItemId must never mint a duplicate id.
|
||||
let profile = json!({
|
||||
"coins": 0,
|
||||
"items": [{"id": 500}],
|
||||
"nextItemId": 10
|
||||
});
|
||||
let mut e = ProfileEconomy::from_fut_profile(&profile);
|
||||
assert_eq!(e.next_item_id(), 501);
|
||||
assert_eq!(e.allocate_item_id(), 501);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
//! FIFA 17 economy POLICY mappers (pure, game-specific).
|
||||
//!
|
||||
//! These translate FIFA 17 wire semantics into the generic amounts the host
|
||||
//! feeds to Core economy authority. They own NO state — Core owns balances and
|
||||
//! inventory; these are the FIFA-specific numbers/derivations. Values are the
|
||||
//! current OpenFUT economy (match rewards are the Python oracle's
|
||||
//! `MATCH_COINS`/`MATCH_PARTICIPATION` at production defaults); pack prices come
|
||||
//! from the Store catalogue.
|
||||
|
||||
use crate::fut::store_catalog::pack_by_id;
|
||||
|
||||
/// Normalized match outcome for reward purposes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MatchResult {
|
||||
Win,
|
||||
Draw,
|
||||
Loss,
|
||||
}
|
||||
|
||||
/// Participation award added to every match reward (oracle `MATCH_PARTICIPATION`
|
||||
/// default = 0).
|
||||
pub const MATCH_PARTICIPATION: i64 = 0;
|
||||
|
||||
/// Per-result match coins (oracle `MATCH_COINS`: won 400 / draw 200 / loss 100).
|
||||
pub fn match_result_coins(result: MatchResult) -> i64 {
|
||||
match result {
|
||||
MatchResult::Win => 400,
|
||||
MatchResult::Draw => 200,
|
||||
MatchResult::Loss => 100,
|
||||
}
|
||||
}
|
||||
|
||||
/// Total match reward = per-result coins + participation.
|
||||
pub fn match_reward_total(result: MatchResult) -> i64 {
|
||||
match_result_coins(result) + MATCH_PARTICIPATION
|
||||
}
|
||||
|
||||
/// Derive the outcome from the match `endReason` enum (the oracle's primary
|
||||
/// signal, `_END_REASON`). Unknown/absent reasons default to `Draw`, matching
|
||||
/// the oracle's conservative default. Score-based derivation is a fallback the
|
||||
/// oracle also supports; the enum is authoritative when present.
|
||||
pub fn result_from_end_reason(end_reason: Option<&str>) -> MatchResult {
|
||||
match end_reason.unwrap_or("").to_ascii_uppercase().as_str() {
|
||||
"WIN" | "DNF_WIN" => MatchResult::Win,
|
||||
"LOSS" | "QUIT" | "DNF" | "DNF_LOSS" => MatchResult::Loss,
|
||||
// "DRAW", "DNF_DRAW", "NO_CONTEST", unknown -> draw.
|
||||
_ => MatchResult::Draw,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Store buy-now price for a pack id (`None` for unknown/owned-only packs,
|
||||
/// which are never purchasable).
|
||||
pub fn pack_price(pack_id: u64) -> Option<u64> {
|
||||
pack_by_id(pack_id)
|
||||
.filter(|p| !p.owned_only)
|
||||
.map(|p| p.price)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn match_rewards_match_oracle() {
|
||||
assert_eq!(match_reward_total(MatchResult::Win), 400);
|
||||
assert_eq!(match_reward_total(MatchResult::Draw), 200);
|
||||
assert_eq!(match_reward_total(MatchResult::Loss), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_reason_maps_to_outcome() {
|
||||
assert_eq!(result_from_end_reason(Some("WIN")), MatchResult::Win);
|
||||
assert_eq!(result_from_end_reason(Some("dnf_win")), MatchResult::Win);
|
||||
assert_eq!(result_from_end_reason(Some("LOSS")), MatchResult::Loss);
|
||||
assert_eq!(result_from_end_reason(Some("QUIT")), MatchResult::Loss);
|
||||
assert_eq!(result_from_end_reason(Some("DRAW")), MatchResult::Draw);
|
||||
assert_eq!(result_from_end_reason(None), MatchResult::Draw);
|
||||
assert_eq!(result_from_end_reason(Some("weird")), MatchResult::Draw);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_price_rejects_unknown_and_owned_only() {
|
||||
assert!(pack_price(1).is_some());
|
||||
assert_eq!(pack_price(65534), None); // sentinel absent from catalogue
|
||||
assert_eq!(pack_price(70), None); // owned-only reward pack, not purchasable
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
//! FIFA 17 entity id ⇄ name resolution, loaded from the committed game-DB
|
||||
//! tables (`leagues.json`, `nations.json`, `teams.json`, dumped from
|
||||
//! `FIFA17.exe`'s resident DB).
|
||||
//!
|
||||
//! Two directions, both FIFA-17-specific and therefore adapter-owned:
|
||||
//! * **forward** id → name — resolves a wire filter (`league=13`) to the
|
||||
//! semantic name Core filters on ("Premier League"). See [`EntityResolver`].
|
||||
//! * **reverse** name → id — shapes a Core item's names back into the numeric
|
||||
//! ids the FIFA `/club` response carries (`leagueId`/`teamid`/`nation`).
|
||||
//!
|
||||
//! Grounding (worker-verified against the tables): forward is 1:1 for all three
|
||||
//! (unique ids, no gaps). Reverse is clean for leagues (50 distinct names) and
|
||||
//! nations (221 distinct); **team names collide** (e.g. `Arsenal` ×3, plus the
|
||||
//! FUT "CHAMPIONS *" placeholder teams), so reverse team lookup is first-id-wins
|
||||
//! and a collision count is exposed for diagnostics. `teamid == assetid` on
|
||||
//! every row.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::fut::owned_query::EntityResolver;
|
||||
|
||||
/// Reverse (name → FIFA id) resolution, used when shaping a Core item back onto
|
||||
/// the FIFA wire. Unknown names return `None`; the shaper substitutes a neutral
|
||||
/// `0` (a valid, non-desyncing int) rather than dropping the item.
|
||||
pub trait ReverseEntityResolver {
|
||||
fn league_id(&self, name: &str) -> Option<u32>;
|
||||
fn nation_id(&self, name: &str) -> Option<u32>;
|
||||
fn team_id(&self, name: &str) -> Option<u32>;
|
||||
}
|
||||
|
||||
/// Forward + reverse FIFA 17 entity maps.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct Fifa17Entities {
|
||||
league_by_id: HashMap<u32, String>,
|
||||
league_by_name: HashMap<String, u32>,
|
||||
nation_by_id: HashMap<u32, String>,
|
||||
nation_by_name: HashMap<String, u32>,
|
||||
team_by_id: HashMap<u32, String>,
|
||||
team_by_name: HashMap<String, u32>,
|
||||
/// name-collisions dropped from the reverse maps (diagnostics only).
|
||||
pub reverse_collisions: usize,
|
||||
}
|
||||
|
||||
/// Errors loading the entity tables.
|
||||
#[derive(Debug)]
|
||||
pub enum LoadError {
|
||||
Io(std::io::Error),
|
||||
Parse { file: String, detail: String },
|
||||
}
|
||||
|
||||
impl std::fmt::Display for LoadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
LoadError::Io(e) => write!(f, "reading entity tables: {e}"),
|
||||
LoadError::Parse { file, detail } => write!(f, "parsing {file}: {detail}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for LoadError {}
|
||||
|
||||
impl Fifa17Entities {
|
||||
/// Load from a directory holding `leagues.json`, `nations.json`,
|
||||
/// `teams.json` in the FIFA17 `{schema, rows:[...]}` dump format.
|
||||
pub fn from_tables_dir(dir: &std::path::Path) -> Result<Self, LoadError> {
|
||||
let mut e = Fifa17Entities::default();
|
||||
e.load_table(
|
||||
&dir.join("leagues.json"),
|
||||
"leagueid",
|
||||
"leaguename",
|
||||
Entity::League,
|
||||
)?;
|
||||
e.load_table(
|
||||
&dir.join("nations.json"),
|
||||
"nationid",
|
||||
"nationname",
|
||||
Entity::Nation,
|
||||
)?;
|
||||
e.load_table(&dir.join("teams.json"), "teamid", "teamname", Entity::Team)?;
|
||||
Ok(e)
|
||||
}
|
||||
|
||||
fn load_table(
|
||||
&mut self,
|
||||
path: &std::path::Path,
|
||||
id_key: &str,
|
||||
name_key: &str,
|
||||
which: Entity,
|
||||
) -> Result<(), LoadError> {
|
||||
let raw = std::fs::read_to_string(path).map_err(LoadError::Io)?;
|
||||
let file = path.display().to_string();
|
||||
let doc: serde_json::Value = serde_json::from_str(&raw).map_err(|e| LoadError::Parse {
|
||||
file: file.clone(),
|
||||
detail: e.to_string(),
|
||||
})?;
|
||||
let rows = doc
|
||||
.get("rows")
|
||||
.and_then(|r| r.as_array())
|
||||
.ok_or_else(|| LoadError::Parse {
|
||||
file: file.clone(),
|
||||
detail: "missing `rows` array".into(),
|
||||
})?;
|
||||
for row in rows {
|
||||
let (Some(id), Some(name)) = (
|
||||
row.get(id_key).and_then(|v| v.as_u64()),
|
||||
row.get(name_key).and_then(|v| v.as_str()),
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
let id = id as u32;
|
||||
let (by_id, by_name) = match which {
|
||||
Entity::League => (&mut self.league_by_id, &mut self.league_by_name),
|
||||
Entity::Nation => (&mut self.nation_by_id, &mut self.nation_by_name),
|
||||
Entity::Team => (&mut self.team_by_id, &mut self.team_by_name),
|
||||
};
|
||||
by_id.insert(id, name.to_string());
|
||||
// Reverse: first id wins on a name collision; count the rest.
|
||||
if by_name.contains_key(name) {
|
||||
self.reverse_collisions += 1;
|
||||
} else {
|
||||
by_name.insert(name.to_string(), id);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build directly from maps (tests / small deployments).
|
||||
pub fn from_maps(
|
||||
leagues: HashMap<u32, String>,
|
||||
nations: HashMap<u32, String>,
|
||||
teams: HashMap<u32, String>,
|
||||
) -> Self {
|
||||
let invert = |m: &HashMap<u32, String>| {
|
||||
let mut out = HashMap::new();
|
||||
for (&id, name) in m {
|
||||
out.entry(name.clone()).or_insert(id);
|
||||
}
|
||||
out
|
||||
};
|
||||
Fifa17Entities {
|
||||
league_by_name: invert(&leagues),
|
||||
nation_by_name: invert(&nations),
|
||||
team_by_name: invert(&teams),
|
||||
league_by_id: leagues,
|
||||
nation_by_id: nations,
|
||||
team_by_id: teams,
|
||||
reverse_collisions: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn league_count(&self) -> usize {
|
||||
self.league_by_id.len()
|
||||
}
|
||||
pub fn nation_count(&self) -> usize {
|
||||
self.nation_by_id.len()
|
||||
}
|
||||
pub fn team_count(&self) -> usize {
|
||||
self.team_by_id.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Entity {
|
||||
League,
|
||||
Nation,
|
||||
Team,
|
||||
}
|
||||
|
||||
impl EntityResolver for Fifa17Entities {
|
||||
fn league_name(&self, id: u32) -> Option<String> {
|
||||
self.league_by_id.get(&id).cloned()
|
||||
}
|
||||
fn nation_name(&self, id: u32) -> Option<String> {
|
||||
self.nation_by_id.get(&id).cloned()
|
||||
}
|
||||
fn team_name(&self, id: u32) -> Option<String> {
|
||||
self.team_by_id.get(&id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
impl ReverseEntityResolver for Fifa17Entities {
|
||||
fn league_id(&self, name: &str) -> Option<u32> {
|
||||
self.league_by_name.get(name).copied()
|
||||
}
|
||||
fn nation_id(&self, name: &str) -> Option<u32> {
|
||||
self.nation_by_name.get(name).copied()
|
||||
}
|
||||
fn team_id(&self, name: &str) -> Option<u32> {
|
||||
self.team_by_name.get(name).copied()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Path to the committed game-DB tables, relative to this crate.
|
||||
fn tables_dir() -> std::path::PathBuf {
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../fifa17-recon/data/tables")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_committed_tables_and_resolves_both_directions() {
|
||||
let dir = tables_dir();
|
||||
if !dir.join("leagues.json").exists() {
|
||||
eprintln!(
|
||||
"skipping: committed tables not present at {}",
|
||||
dir.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
let e = Fifa17Entities::from_tables_dir(&dir).expect("load tables");
|
||||
assert_eq!(e.league_count(), 50);
|
||||
assert_eq!(e.nation_count(), 221);
|
||||
assert_eq!(e.team_count(), 750);
|
||||
// forward id -> name (grounding pinned in owned_query too)
|
||||
assert_eq!(e.league_name(13).as_deref(), Some("Premier League"));
|
||||
assert_eq!(e.nation_name(52).as_deref(), Some("Argentina"));
|
||||
assert_eq!(e.team_name(5).as_deref(), Some("Chelsea"));
|
||||
// reverse name -> id (clean for leagues/nations)
|
||||
assert_eq!(e.league_id("Premier League"), Some(13));
|
||||
assert_eq!(e.nation_id("Argentina"), Some(52));
|
||||
assert_eq!(e.team_id("Chelsea"), Some(5));
|
||||
// unknown -> None (never a raw-id fallback)
|
||||
assert_eq!(e.league_name(999_999), None);
|
||||
assert_eq!(e.team_id("Northgate United"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_maps_inverts() {
|
||||
let e = Fifa17Entities::from_maps(
|
||||
HashMap::from([(13, "Premier League".to_string())]),
|
||||
HashMap::from([(52, "Argentina".to_string())]),
|
||||
HashMap::from([(5, "Chelsea".to_string())]),
|
||||
);
|
||||
assert_eq!(e.league_id("Premier League"), Some(13));
|
||||
assert_eq!(e.team_name(5).as_deref(), Some("Chelsea"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! The shared FIFA 17 FUT **item-shaping primitive**.
|
||||
//!
|
||||
//! One function shapes one owned FUT item into the numeric card object the FIFA
|
||||
//! 17 client renders, and **every** route that emits a player item goes through
|
||||
//! it — `/club` (via [`crate::fut::club_response`]) and squad projection (via
|
||||
//! [`crate::fut::squad_projection`]) alike. There is deliberately no second copy
|
||||
//! of the field set: an item is shaped in exactly one place so the two routes
|
||||
//! can never drift.
|
||||
//!
|
||||
//! ## The asset-id boundary (load-bearing, evidence-grounded)
|
||||
//!
|
||||
//! FIFA renders a card by resolving `resourceId & 0xffffff` against the client's
|
||||
//! OWN local players table (proven live): a real id renders a real footballer,
|
||||
//! an **invented id renders a blank generic card**. OpenFUT Core's catalogue is
|
||||
//! synthetic string ids (`card_pl_001`) with no FIFA asset id. So an
|
||||
//! [`ItemIdentityResolver`] is injected; when it cannot supply a **real** FIFA
|
||||
//! asset id for an item, the item carries no fabricated identity — the caller
|
||||
//! decides what that means (`/club` drops and counts it; a squad refuses to
|
||||
//! project a starter it cannot render, never faking one).
|
||||
//!
|
||||
//! Entity ids (`leagueId`/`teamid`/`nation`) come from a reverse resolver; an
|
||||
//! unresolved name yields a neutral `0` (a valid int — non-fatal; it only means
|
||||
//! "no badge/flag"), because those are not the identity the renderer keys on.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::content_taxonomy::ContentKind;
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
|
||||
/// One owned item in game-independent terms, as read from Core's inventory.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CoreOwnedItem {
|
||||
/// Core owned-instance id (string). The stable per-copy identity — two
|
||||
/// copies of the same card definition have distinct `owned_card_id`s.
|
||||
pub owned_card_id: String,
|
||||
/// Core card-definition id (string), used for asset resolution.
|
||||
pub card_id: String,
|
||||
/// Effective overall rating.
|
||||
pub rating: u8,
|
||||
/// Effective position, e.g. "ST".
|
||||
pub position: String,
|
||||
pub nation: String,
|
||||
pub league: String,
|
||||
pub club: String,
|
||||
/// [pace, shooting, passing, dribbling, defending, physical].
|
||||
pub attributes: [u8; 6],
|
||||
}
|
||||
|
||||
/// The FIFA-side numeric identity of an owned item. `asset_id` MUST be a real
|
||||
/// FIFA player asset (low 24 bits the client resolves); `item_id` is the wire
|
||||
/// instance id used for later item operations. Two owned copies of the same
|
||||
/// definition share an `asset_id` but MUST have distinct `item_id`s.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Fifa17Identity {
|
||||
pub item_id: u32,
|
||||
/// Base FIFA player asset (low 24 bits the client resolves art/name from).
|
||||
pub asset_id: u32,
|
||||
/// Full versioned resource id = `(version << 24) | asset_id`. Equals
|
||||
/// `asset_id` for a version-0 base card. This is the wire
|
||||
/// `resourceId`/`definitionId`, kept DISTINCT from `asset_id` so a versioned
|
||||
/// (special) card never collapses onto its base on the wire.
|
||||
pub resource_id: u32,
|
||||
/// FIFA wire `rareflag` — the card's rare/special TYPE (e.g. 3=inform,
|
||||
/// 21..=24 = special programmes). Drives the client's special-card art;
|
||||
/// carried from the catalog, never hardcoded, so specials render as specials.
|
||||
pub rareflag: i64,
|
||||
}
|
||||
|
||||
/// Supplies the FIFA numeric identity for a Core item. Returning `None` means
|
||||
/// "no real FIFA asset id known" → the caller must not fabricate one.
|
||||
pub trait ItemIdentityResolver {
|
||||
fn resolve(&self, item: &CoreOwnedItem) -> Option<Fifa17Identity>;
|
||||
|
||||
/// Classify a Core item's definition as player/consumable/staff. Defaults to
|
||||
/// [`ContentKind::Player`] so existing resolvers keep their behaviour; a
|
||||
/// catalog-backed resolver overrides this to consult its `kind_of`, letting
|
||||
/// `/club` exclude non-player content (which must never render as a
|
||||
/// 0-rated player).
|
||||
fn kind_of(&self, _item: &CoreOwnedItem) -> ContentKind {
|
||||
ContentKind::Player
|
||||
}
|
||||
}
|
||||
|
||||
/// Diagnostics from shaping (safe to log — counts only).
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ShapeStats {
|
||||
pub emitted: usize,
|
||||
pub dropped_no_asset: usize,
|
||||
/// Consumable/staff items excluded from a player projection (they must never
|
||||
/// render as a 0-rated player). Counted, never emitted.
|
||||
pub excluded_non_player: usize,
|
||||
}
|
||||
|
||||
/// Quick-sell / discard value by rating tier (mirrors Core's quick-sell table;
|
||||
/// non-fatal display field).
|
||||
fn discard_value(rating: u8) -> i64 {
|
||||
match rating {
|
||||
r if r >= 85 => 1500,
|
||||
r if r >= 80 => 900,
|
||||
r if r >= 75 => 600,
|
||||
r if r >= 65 => 300,
|
||||
_ => 150,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build one FIFA `_item` object. `resourceId`/`definitionId` carry the full
|
||||
/// versioned resource id; `assetId`/`cardassetid` carry the base asset. For a
|
||||
/// version-0 base card these coincide; for a special they differ and MUST NOT
|
||||
/// be collapsed.
|
||||
///
|
||||
/// This is the single source of truth for a player item's on-wire shape; the
|
||||
/// `/club` envelope and squad projection both call it, so their items are
|
||||
/// identical by construction. `id` is the owned instance's resolved FIFA
|
||||
/// identity — pass the resolver's answer for *this* owned copy so two copies of
|
||||
/// one definition stay distinct on the wire.
|
||||
pub fn shape_item(
|
||||
item: &CoreOwnedItem,
|
||||
id: Fifa17Identity,
|
||||
ent: &impl ReverseEntityResolver,
|
||||
) -> Value {
|
||||
let asset = id.asset_id;
|
||||
let league_id = ent.league_id(&item.league).unwrap_or(0);
|
||||
let team_id = ent.team_id(&item.club).unwrap_or(0);
|
||||
let nation_id = ent.nation_id(&item.nation).unwrap_or(0);
|
||||
let attribute_list: Vec<Value> = item
|
||||
.attributes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| json!({ "index": i, "value": v }))
|
||||
.collect();
|
||||
json!({
|
||||
"id": id.item_id,
|
||||
"resourceId": id.resource_id,
|
||||
"assetId": asset,
|
||||
"cardassetid": asset,
|
||||
"definitionId": id.resource_id,
|
||||
"cardsubtypeid": 0,
|
||||
"itemType": "player",
|
||||
"rareflag": id.rareflag,
|
||||
"rating": item.rating,
|
||||
"preferredPosition": item.position,
|
||||
"nation": nation_id,
|
||||
"teamid": team_id,
|
||||
"leagueId": league_id,
|
||||
"playStyle": 250,
|
||||
"attributeList": attribute_list,
|
||||
"itemState": "free",
|
||||
"owners": 1,
|
||||
// Owned/pack-pulled cards are TRADEABLE in FIFA 17 (untradeable is the
|
||||
// exception for SBC/promo rewards, which Core does not model). Emitting
|
||||
// `true` greyed out "Place on Transfer Market" for every card — the same
|
||||
// "our own data showing through" bug the Python oracle fixed by forcing
|
||||
// this off for owned copies (item_def keeps `true`; instances do not).
|
||||
"untradeable": false,
|
||||
"contract": 7,
|
||||
"fitness": 99,
|
||||
"discardValue": discard_value(item.rating),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fut::entities::Fifa17Entities;
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn entities() -> Fifa17Entities {
|
||||
Fifa17Entities::from_maps(
|
||||
HashMap::from([(13, "Premier League".to_string())]),
|
||||
HashMap::from([(52, "Argentina".to_string())]),
|
||||
HashMap::from([(5, "Chelsea".to_string())]),
|
||||
)
|
||||
}
|
||||
|
||||
fn item(owned: &str, card: &str, rating: u8, pos: &str) -> CoreOwnedItem {
|
||||
CoreOwnedItem {
|
||||
owned_card_id: owned.into(),
|
||||
card_id: card.into(),
|
||||
rating,
|
||||
position: pos.into(),
|
||||
nation: "Argentina".into(),
|
||||
league: "Premier League".into(),
|
||||
club: "Chelsea".into(),
|
||||
attributes: [90, 88, 70, 85, 40, 78],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shapes_real_identity_and_reverse_entity_ids() {
|
||||
let ent = entities();
|
||||
let it = shape_item(
|
||||
&item("oc1", "card_ch_1", 86, "CDM"),
|
||||
Fifa17Identity {
|
||||
item_id: 100000001,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
&ent,
|
||||
);
|
||||
assert_eq!(it["id"], 100000001, "wire instance id");
|
||||
assert_eq!(it["resourceId"], 20801);
|
||||
assert_eq!(it["assetId"], 20801);
|
||||
assert_eq!(
|
||||
it["definitionId"], 20801,
|
||||
"version byte 0 => resourceId==assetId==definitionId"
|
||||
);
|
||||
assert_eq!(it["rating"], 86);
|
||||
assert_eq!(it["preferredPosition"], "CDM");
|
||||
assert_eq!(it["leagueId"], 13);
|
||||
assert_eq!(it["teamid"], 5);
|
||||
assert_eq!(it["nation"], 52);
|
||||
assert_eq!(it["itemType"], "player");
|
||||
assert_eq!(it["attributeList"].as_array().unwrap().len(), 6);
|
||||
assert_eq!(it["attributeList"][0], json!({"index":0,"value":90}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_owned_copies_of_one_definition_stay_distinct_on_the_wire() {
|
||||
// fifa17_101490 has two owned instances: same definition/asset, two
|
||||
// distinct owned ids and two distinct wire ids. Shaping each from its
|
||||
// own identity must NEVER collapse them.
|
||||
let ent = entities();
|
||||
let a = shape_item(
|
||||
&item("oc-a", "fifa17_101490", 84, "ST"),
|
||||
Fifa17Identity {
|
||||
item_id: 100000030,
|
||||
asset_id: 101490,
|
||||
resource_id: 101490,
|
||||
rareflag: 1,
|
||||
},
|
||||
&ent,
|
||||
);
|
||||
let b = shape_item(
|
||||
&item("oc-b", "fifa17_101490", 84, "ST"),
|
||||
Fifa17Identity {
|
||||
item_id: 100000031,
|
||||
asset_id: 101490,
|
||||
resource_id: 101490,
|
||||
rareflag: 1,
|
||||
},
|
||||
&ent,
|
||||
);
|
||||
assert_eq!(
|
||||
a["resourceId"], b["resourceId"],
|
||||
"same definition => same asset"
|
||||
);
|
||||
assert_ne!(
|
||||
a["id"], b["id"],
|
||||
"distinct owned copies keep distinct wire ids"
|
||||
);
|
||||
assert_eq!(a["id"], 100000030);
|
||||
assert_eq!(b["id"], 100000031);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn versioned_special_keeps_resourceid_distinct_from_assetid() {
|
||||
// A versioned (special) card: resourceId/definitionId carry the full
|
||||
// versioned id; assetId/cardassetid stay the base asset. They MUST NOT
|
||||
// collapse. (resource 117617092 = version 7 of asset 176580.)
|
||||
let ent = entities();
|
||||
let it = shape_item(
|
||||
&item("oc-v", "fifa17_117617092", 92, "ST"),
|
||||
Fifa17Identity {
|
||||
item_id: 100000384,
|
||||
asset_id: 176580,
|
||||
resource_id: 117617092,
|
||||
rareflag: 3,
|
||||
},
|
||||
&ent,
|
||||
);
|
||||
assert_eq!(
|
||||
it["resourceId"], 117617092,
|
||||
"versioned resource id on the wire"
|
||||
);
|
||||
assert_eq!(it["definitionId"], 117617092);
|
||||
assert_eq!(it["assetId"], 176580, "base asset id preserved");
|
||||
assert_eq!(it["cardassetid"], 176580);
|
||||
assert_eq!(
|
||||
it["rareflag"], 3,
|
||||
"special rareflag carried, not hardcoded 1"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! FIFA 17 FUT (UTAS/RS4) wire → OpenFUT Core semantic mappings.
|
||||
//!
|
||||
//! Unlike [`crate::blaze`] (binary Blaze RPC), this is the JSON/HTTP FUT surface.
|
||||
//! It currently holds the owned-player ("My Squad") search mapping; more UTAS
|
||||
//! routes join it as the UTAS→Core migration proceeds. Nothing here opens a
|
||||
//! socket — a Rust UTAS host wires it to Core later.
|
||||
pub mod catalog;
|
||||
pub mod club_response;
|
||||
pub mod club_stats;
|
||||
pub mod content_taxonomy;
|
||||
pub mod economy;
|
||||
pub mod economy_policy;
|
||||
pub mod entities;
|
||||
pub mod item;
|
||||
pub mod non_economy;
|
||||
pub mod owned_query;
|
||||
pub mod pack_content;
|
||||
pub mod squad;
|
||||
pub mod squad_ext;
|
||||
pub mod squad_projection;
|
||||
pub mod store_catalog;
|
||||
pub mod store_session;
|
||||
@@ -0,0 +1,675 @@
|
||||
//! FIFA17 non-economy presentation routes, migrated from the Python oracle.
|
||||
//!
|
||||
//! Pure, IO-free shapers that reproduce the **observed production** oracle
|
||||
//! contract (`fifa17-recon/tools/utas_server.py`) for the non-economy UTAS
|
||||
//! routes a Rust host can own without any Core or account state:
|
||||
//!
|
||||
//! * `GET …/user/accountinfo` → `{}` (FUT_ACCOUNTINFO off)
|
||||
//! * `GET …/settings` → `{"configs":[]}` (FUT_SETTINGS off)
|
||||
//! * `GET …/leaderboards/options` → `{}` (FUT_MODES off)
|
||||
//! * `PUT …/match/reset` → `{}` (Tier-B no-op ack)
|
||||
//! * `GET/POST/PUT …/phishing/{trusteddevice,question,validate}` — the retired
|
||||
//! FUT security-question service, a stateless acknowledgement.
|
||||
//!
|
||||
//! These match the bodies the live prod oracle actually returns under the
|
||||
//! production environment (`FUT_TRADING=1 FUT_PILESIZES=1 FUT_TRADEABLE=1`, none
|
||||
//! of the feature flags set). They carry no persisted state: the security
|
||||
//! record the oracle seeds (`{version:1,verified:true}`) is log-only — the
|
||||
//! response is invariant — so a faithful Rust owner needs no persistence.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// `GET …/user/accountinfo` — production oracle returns an empty object.
|
||||
pub fn accountinfo_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// `GET …/settings` — production oracle returns an empty config list.
|
||||
pub fn settings_body() -> Value {
|
||||
json!({ "configs": [] })
|
||||
}
|
||||
|
||||
/// `GET …/leaderboards/options` — production oracle (FUT_MODES off) returns an
|
||||
/// empty object; the retail client ignores the body.
|
||||
pub fn leaderboard_options_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// `PUT …/match/reset` — Tier-B no-op acknowledgement.
|
||||
pub fn match_reset_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// `GET …/club/stats/staff` — production oracle returns an empty object (FIFA's
|
||||
/// staff-bonus stat set is deliberately empty; the client tolerates `{}`).
|
||||
pub fn club_stats_staff_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// The FUT modes (Seasons / Tournaments / FUT Champions) and the club-identity
|
||||
/// service are disabled in this emulator, so their read routes (`season`,
|
||||
/// `tournament`, `champion`, `clubUser`, `user/list`) return an empty object —
|
||||
/// byte-identical to the Python oracle with `FUT_MODES` / `FUT_CLUB_IDENTITY`
|
||||
/// off. Enabling a mode later requires a real Rust implementation here, never a
|
||||
/// Python fallback (which would reintroduce split authority).
|
||||
pub fn feature_off_body() -> Value {
|
||||
json!({})
|
||||
}
|
||||
|
||||
/// One FUT item-definition for a requested `resource_id`, replicating the Python
|
||||
/// oracle's `item_def`: `assetId = resource_id & 0xffffff`; a single hardcoded
|
||||
/// card (Ronaldo, asset 20801) and a generic placeholder (`"Player"`, 75, CM,
|
||||
/// attrs 70) for every other asset. The FIFA client renders the real card from
|
||||
/// its LOCAL DB from the `(rareflag, resourceId)` pair, so this route only needs
|
||||
/// a valid-shaped record — the placeholder is exactly what the oracle itself
|
||||
/// returns for all but the one hardcoded asset. Key order is irrelevant (the
|
||||
/// client's deserializer is key-addressed and skip-safe).
|
||||
pub fn item_def(resource_id: i64) -> Value {
|
||||
let asset = resource_id & 0xff_ffff;
|
||||
// (name, rating, position, nation, leagueId, teamid, [6 attrs])
|
||||
let (name, rating, pos, nation, league, team, attrs): (&str, i64, &str, i64, i64, i64, [i64; 6]) =
|
||||
if asset == 20801 {
|
||||
("Ronaldo", 94, "ST", 38, 53, 243, [90, 93, 82, 91, 33, 80])
|
||||
} else {
|
||||
("Player", 75, "CM", 0, 0, 0, [70, 70, 70, 70, 70, 70])
|
||||
};
|
||||
let attribute_list: Vec<Value> = attrs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| json!({ "index": i, "value": v }))
|
||||
.collect();
|
||||
json!({
|
||||
"id": resource_id,
|
||||
"resourceId": resource_id,
|
||||
"definitionId": resource_id,
|
||||
"assetId": asset,
|
||||
"cardassetid": asset,
|
||||
"commodityId": asset,
|
||||
"cardsubtypeid": 0,
|
||||
"cardType": 0,
|
||||
"itemType": "player",
|
||||
"rareflag": 1,
|
||||
"rating": rating,
|
||||
"preferredPosition": pos,
|
||||
"nation": nation,
|
||||
"leagueId": league,
|
||||
"teamid": team,
|
||||
"playStyle": 250,
|
||||
"attributeList": attribute_list,
|
||||
"name": name,
|
||||
"commonName": name,
|
||||
"lastName": name,
|
||||
"itemState": "free",
|
||||
"untradeable": true,
|
||||
})
|
||||
}
|
||||
|
||||
/// `GET …/item/resource`, `…/defid` — `{itemData:[…]}` with one [`item_def`] per
|
||||
/// requested id (mirrors the oracle's `defs_route`). No ids → an empty list.
|
||||
pub fn item_defs_body(ids: &[i64]) -> Value {
|
||||
json!({ "itemData": ids.iter().map(|&id| item_def(id)).collect::<Vec<_>>() })
|
||||
}
|
||||
|
||||
/// `GET …/marketdata/pricelimits?defId=a,b,c` — FutGetSuggestedPricing. The root
|
||||
/// MUST be a BARE ARRAY (one element per defId): returning an object here froze a
|
||||
/// live client (object-where-array busy loop at the listing screen). Constant
|
||||
/// band 150..15000 (placeholder pricing; not a freeze concern).
|
||||
pub fn marketdata_pricelimits_body(def_ids: &[i64]) -> Value {
|
||||
json!(def_ids
|
||||
.iter()
|
||||
.map(|&d| json!({ "defId": d, "minPrice": 150, "maxPrice": 15000 }))
|
||||
.collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
/// `GET …/marketdata` (NOT `/pricelimits`) — the price-comparison endpoint, which
|
||||
/// takes an OBJECT `{minPrice,maxPrice}`. Array-where-object would be the same
|
||||
/// freeze in reverse, so the container type is load-bearing. Constant band.
|
||||
pub fn marketdata_object_body() -> Value {
|
||||
json!({ "minPrice": 150, "maxPrice": 15000 })
|
||||
}
|
||||
|
||||
/// The phishing/security-question action, parsed from the URL tail.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SecurityAction {
|
||||
TrustedDevice,
|
||||
Question,
|
||||
Validate,
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// The `phishing/<action>` selector from a `…/phishing/<action>` path tail.
|
||||
pub fn parse_security_action(tail: &str) -> SecurityAction {
|
||||
let last = tail.trim_end_matches('/').rsplit('/').next().unwrap_or("");
|
||||
match last {
|
||||
"trusteddevice" => SecurityAction::TrustedDevice,
|
||||
"question" => SecurityAction::Question,
|
||||
"validate" => SecurityAction::Validate,
|
||||
_ => SecurityAction::Unknown,
|
||||
}
|
||||
}
|
||||
|
||||
/// A well-formed FUT phishing token is an opaque 32-char lowercase/uppercase hex
|
||||
/// value (`_PHISHING_HEX32.fullmatch` in the oracle).
|
||||
fn is_hex32(s: &str) -> bool {
|
||||
s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
}
|
||||
|
||||
/// Reproduce `utas_server.py::security_question_route` verbatim.
|
||||
///
|
||||
/// The retired FUT security-question service is a stateless acknowledgement: it
|
||||
/// validates request shape (session, 32-hex device id, 32-hex answer, numeric
|
||||
/// question) and returns fixed bodies. The answer is a client-transformed opaque
|
||||
/// value that is **never stored or compared**; the trusted-device response is an
|
||||
/// invariant `verified/trusted` constant.
|
||||
///
|
||||
/// * `session_known` — whether `X-UT-SID` maps to an open Rust session.
|
||||
/// * `device_id` / `question` / `answer` — decoded query parameters (`""`/`None`
|
||||
/// when absent).
|
||||
///
|
||||
/// Returns `(http_status, body)`.
|
||||
pub fn security_question_response(
|
||||
method: &str,
|
||||
action: SecurityAction,
|
||||
session_known: bool,
|
||||
device_id: &str,
|
||||
question: Option<&str>,
|
||||
answer: Option<&str>,
|
||||
) -> (u16, Value) {
|
||||
let is = |m: &str| method.eq_ignore_ascii_case(m);
|
||||
if !session_known {
|
||||
return (400, json!({ "reason": "invalid_session" }));
|
||||
}
|
||||
if !is_hex32(device_id) {
|
||||
return (400, json!({ "reason": "malformed_request" }));
|
||||
}
|
||||
match action {
|
||||
SecurityAction::TrustedDevice => {
|
||||
if !is("GET") {
|
||||
return (405, json!({ "reason": "method_not_allowed" }));
|
||||
}
|
||||
(
|
||||
200,
|
||||
json!({ "changed": false, "exists": true, "locked": false, "trusted": true }),
|
||||
)
|
||||
}
|
||||
SecurityAction::Question if is("GET") => (
|
||||
200,
|
||||
json!({ "question": 0, "attempts": 5, "recoverAttempts": 0 }),
|
||||
),
|
||||
SecurityAction::Question if is("POST") || is("PUT") => {
|
||||
let q = question.unwrap_or("");
|
||||
let a = answer.unwrap_or("");
|
||||
if q.is_empty() || !q.bytes().all(|b| b.is_ascii_digit()) || !is_hex32(a) {
|
||||
return (400, json!({ "reason": "malformed_request" }));
|
||||
}
|
||||
(200, json!({}))
|
||||
}
|
||||
SecurityAction::Validate if is("POST") => {
|
||||
let a = answer.unwrap_or("");
|
||||
if !is_hex32(a) {
|
||||
return (400, json!({ "reason": "malformed_request" }));
|
||||
}
|
||||
(200, json!({}))
|
||||
}
|
||||
_ => (405, json!({ "reason": "method_not_allowed" })),
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────── POST /openfut/account/sync (Rust-owned) ─────────────────
|
||||
|
||||
/// The launcher `account/sync` request fields, with production defaults already
|
||||
/// applied. Everything is optional in the wire body; missing fields fall back to
|
||||
/// the fixed defaults the launcher expects. `personaId` defaults to the host's
|
||||
/// configured persona (passed in), never a baked-in constant.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AccountSyncRequest {
|
||||
pub persona_id: i64,
|
||||
pub persona_name: String,
|
||||
pub level: i64,
|
||||
pub experience: i64,
|
||||
pub experience_max: i64,
|
||||
pub account_funds: i64,
|
||||
pub account_funds_cap: i64,
|
||||
}
|
||||
|
||||
/// The FIFA persona display name for this emulator's single account.
|
||||
///
|
||||
/// The oracle sources this from the shared account (`fut_account.py`, default
|
||||
/// `"CAGE"`) and documents the property as "Blaze PDTL.DSNM / LSX
|
||||
/// GetProfileResponse Persona / **UTAS sellerName**". That last role is
|
||||
/// load-bearing: the client decides whether a transfer-market listing is the
|
||||
/// player's OWN — and therefore whether to offer Remove / Re-list at all — from
|
||||
/// the seller identity on the auction record. Stamping EA's house name there
|
||||
/// makes the player's own listing un-actionable (pressing it opens no dialog).
|
||||
pub const PERSONA_DISPLAY_NAME: &str = "CAGE";
|
||||
|
||||
/// Parse the `account/sync` request body, applying every default. `default_persona`
|
||||
/// is the host's configured persona id (used when `personaId` is absent).
|
||||
pub fn parse_account_sync(body: &[u8], default_persona: i64) -> AccountSyncRequest {
|
||||
let v: Value = serde_json::from_slice(body).unwrap_or(Value::Null);
|
||||
let int = |key: &str, dflt: i64| v.get(key).and_then(Value::as_i64).unwrap_or(dflt);
|
||||
let persona_name = v
|
||||
.get("personaName")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or(PERSONA_DISPLAY_NAME)
|
||||
.to_string();
|
||||
AccountSyncRequest {
|
||||
persona_id: int("personaId", default_persona),
|
||||
persona_name,
|
||||
level: int("level", 1),
|
||||
experience: int("experience", 0),
|
||||
experience_max: int("experienceMax", 1000),
|
||||
account_funds: int("accountFunds", 0),
|
||||
account_funds_cap: int("accountFundsCap", 100000),
|
||||
}
|
||||
}
|
||||
|
||||
/// `POST /openfut/account/sync` — the launcher control-plane account summary.
|
||||
/// `coins`/`unopened_packs` are the AUTHORITATIVE Core values (balance +
|
||||
/// entitlement count), never Python's stale profile funds.
|
||||
pub fn account_sync_body(req: &AccountSyncRequest, coins: i64, unopened_packs: usize) -> Value {
|
||||
json!({
|
||||
"account": {
|
||||
"personaId": req.persona_id,
|
||||
"personaName": req.persona_name,
|
||||
"clubName": "OpenFUT",
|
||||
"clubAbbr": "OFC",
|
||||
"level": req.level,
|
||||
"experience": req.experience,
|
||||
"experienceMax": req.experience_max,
|
||||
"accountFunds": req.account_funds,
|
||||
"accountFundsCap": req.account_funds_cap,
|
||||
"profilePath": "accounts/33068179/fifa17_profile.json",
|
||||
"coins": coins,
|
||||
"unopenedPacks": unopened_packs,
|
||||
},
|
||||
"status": "OK",
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────── GET …/userMassInfo (Rust-owned) ─────────────────────
|
||||
|
||||
/// Build the full `GET …/userMassInfo` body entirely in Rust (no Python).
|
||||
///
|
||||
/// `squad` is the Core-projected active squad (`user_mass_info_squad` output), so
|
||||
/// it is byte-for-byte the object `GET …/squad/active` embeds. `coins` and
|
||||
/// `unopened_packs` are the authoritative Core economy values. `userInfo.actives`
|
||||
/// mirrors the squad's `actives` (capped at 5), and `userInfo.squadList` is the
|
||||
/// summary of the current squad.
|
||||
pub fn user_mass_info_body(
|
||||
squad: Value,
|
||||
coins: i64,
|
||||
unopened_packs: usize,
|
||||
persona_id: i64,
|
||||
) -> Value {
|
||||
let actives: Vec<Value> = squad
|
||||
.get("actives")
|
||||
.and_then(Value::as_array)
|
||||
.map(|a| a.iter().take(5).cloned().collect())
|
||||
.unwrap_or_default();
|
||||
let squad_list = crate::fut::squad_projection::squad_list(&squad);
|
||||
let mut user_info = json!({
|
||||
"personaId": persona_id,
|
||||
"clubName": "OpenFUT",
|
||||
"clubAbbr": "OFC",
|
||||
"established": "2026",
|
||||
"accountCreatedPlatformName": "pc",
|
||||
"currencies": [
|
||||
{"name": "coins", "funds": coins, "finalFunds": coins, "active": true},
|
||||
{"name": "points", "funds": 0, "finalFunds": 0, "active": true},
|
||||
],
|
||||
"won": 0,
|
||||
"draw": 0,
|
||||
"loss": 0,
|
||||
"clubNameChangeAllowed": false,
|
||||
"divisionOffline": 10,
|
||||
"divisionOnline": 10,
|
||||
"purchased": false,
|
||||
"feature": {},
|
||||
"reliability": {"reliability": 100, "matchUnfinishedTime": 0},
|
||||
"bidTokens": {"count": 0, "updateTime": 0},
|
||||
"trophies": 0,
|
||||
"sessionCoinsBankBalance": 0,
|
||||
"actives": actives,
|
||||
"squadList": squad_list,
|
||||
});
|
||||
if unopened_packs > 0 {
|
||||
user_info.as_object_mut().unwrap().insert(
|
||||
"unopenedPacks".into(),
|
||||
json!({"preOrderPacks": 0, "recoveredPacks": unopened_packs}),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"pileSizeClientData": {"entries": [{"key": 2, "value": 100}, {"key": 4, "value": 50}]},
|
||||
"settings": {"configs": []},
|
||||
"userData": {},
|
||||
"squad": squad,
|
||||
"userInfo": user_info,
|
||||
})
|
||||
}
|
||||
|
||||
// ───────────────────────────── POST /ut/auth (Rust) ──────────────────────────
|
||||
|
||||
/// Format `epoch_secs` (seconds since the Unix epoch) as UTC
|
||||
/// `YYYY-MM-DD HH:MM:SS`. Pure civil-date arithmetic (Howard Hinnant's
|
||||
/// `civil_from_days`), so no `time`/`chrono` dependency is needed.
|
||||
pub fn format_utc_datetime(epoch_secs: i64) -> String {
|
||||
let days = epoch_secs.div_euclid(86_400);
|
||||
let secs_of_day = epoch_secs.rem_euclid(86_400);
|
||||
let (hour, min, sec) = (secs_of_day / 3600, (secs_of_day % 3600) / 60, secs_of_day % 60);
|
||||
// civil_from_days: days is a count of days since 1970-01-01.
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097; // [0, 146096]
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
|
||||
let year = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
|
||||
let mp = (5 * doy + 2) / 153; // [0, 11]
|
||||
let day = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
|
||||
let month = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
|
||||
let year = if month <= 2 { year + 1 } else { year };
|
||||
format!("{year:04}-{month:02}-{day:02} {hour:02}:{min:02}:{sec:02}")
|
||||
}
|
||||
|
||||
/// The persona a `/ut/auth` request adopts: `nucleusPersonaId` or `nuc` from the
|
||||
/// body (numeric or numeric string), else `None` (the host substitutes its
|
||||
/// configured persona). The client is never refused.
|
||||
pub fn parse_auth_persona(body: &[u8]) -> Option<i64> {
|
||||
let v: Value = serde_json::from_slice(body).ok()?;
|
||||
let field = |key: &str| {
|
||||
v.get(key).and_then(|x| {
|
||||
x.as_i64()
|
||||
.or_else(|| x.as_str().and_then(|s| s.parse::<i64>().ok()))
|
||||
})
|
||||
};
|
||||
field("nucleusPersonaId").or_else(|| field("nuc"))
|
||||
}
|
||||
|
||||
/// `POST /ut/auth` response body. `sid` is the freshly minted Rust session id;
|
||||
/// `server_time` is UTC `YYYY-MM-DD HH:MM:SS` (also used for `lastOnlineTime`).
|
||||
pub fn auth_body(sid: &str, server_time: &str) -> Value {
|
||||
json!({
|
||||
"protocol": 1,
|
||||
"sid": sid,
|
||||
"serverTime": server_time,
|
||||
"lastOnlineTime": server_time,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const DEV: &str = "6236375476659cd0f6c780e728774b71"; // 32-hex (live deviceId)
|
||||
const ANS: &str = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
#[test]
|
||||
fn static_bodies_match_oracle() {
|
||||
assert_eq!(accountinfo_body(), json!({}));
|
||||
assert_eq!(settings_body(), json!({ "configs": [] }));
|
||||
assert_eq!(leaderboard_options_body(), json!({}));
|
||||
assert_eq!(match_reset_body(), json!({}));
|
||||
assert_eq!(club_stats_staff_body(), json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_parse() {
|
||||
assert_eq!(
|
||||
parse_security_action("phishing/trusteddevice"),
|
||||
SecurityAction::TrustedDevice
|
||||
);
|
||||
assert_eq!(
|
||||
parse_security_action("phishing/question/"),
|
||||
SecurityAction::Question
|
||||
);
|
||||
assert_eq!(
|
||||
parse_security_action("phishing/validate"),
|
||||
SecurityAction::Validate
|
||||
);
|
||||
assert_eq!(
|
||||
parse_security_action("phishing/other"),
|
||||
SecurityAction::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_device_verified_constant() {
|
||||
let (s, b) =
|
||||
security_question_response("GET", SecurityAction::TrustedDevice, true, DEV, None, None);
|
||||
assert_eq!(s, 200);
|
||||
assert_eq!(
|
||||
b,
|
||||
json!({ "changed": false, "exists": true, "locked": false, "trusted": true })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_session_is_400_invalid_session() {
|
||||
let (s, b) = security_question_response(
|
||||
"GET",
|
||||
SecurityAction::TrustedDevice,
|
||||
false,
|
||||
DEV,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(s, 400);
|
||||
assert_eq!(b, json!({ "reason": "invalid_session" }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_device_id_is_malformed() {
|
||||
for bad in [
|
||||
"",
|
||||
"xyz",
|
||||
"6236375476659cd0f6c780e728774b7",
|
||||
"not-hex-not-hex-not-hex-not-hexx",
|
||||
] {
|
||||
let (s, b) = security_question_response(
|
||||
"GET",
|
||||
SecurityAction::TrustedDevice,
|
||||
true,
|
||||
bad,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(s, 400, "device {bad:?}");
|
||||
assert_eq!(b, json!({ "reason": "malformed_request" }));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trusted_device_wrong_method_405() {
|
||||
let (s, _) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::TrustedDevice,
|
||||
true,
|
||||
DEV,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
assert_eq!(s, 405);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_get_returns_prompt() {
|
||||
let (s, b) =
|
||||
security_question_response("GET", SecurityAction::Question, true, DEV, None, None);
|
||||
assert_eq!(s, 200);
|
||||
assert_eq!(
|
||||
b,
|
||||
json!({ "question": 0, "attempts": 5, "recoverAttempts": 0 })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn question_setup_validates_shape() {
|
||||
// valid numeric question + 32-hex answer
|
||||
let (s, b) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::Question,
|
||||
true,
|
||||
DEV,
|
||||
Some("0"),
|
||||
Some(ANS),
|
||||
);
|
||||
assert_eq!(s, 200);
|
||||
assert_eq!(b, json!({}));
|
||||
// empty question -> malformed
|
||||
let (s, _) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::Question,
|
||||
true,
|
||||
DEV,
|
||||
Some(""),
|
||||
Some(ANS),
|
||||
);
|
||||
assert_eq!(s, 400);
|
||||
// non-digit question -> malformed
|
||||
let (s, _) = security_question_response(
|
||||
"PUT",
|
||||
SecurityAction::Question,
|
||||
true,
|
||||
DEV,
|
||||
Some("x"),
|
||||
Some(ANS),
|
||||
);
|
||||
assert_eq!(s, 400);
|
||||
// bad answer -> malformed
|
||||
let (s, _) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::Question,
|
||||
true,
|
||||
DEV,
|
||||
Some("0"),
|
||||
Some("short"),
|
||||
);
|
||||
assert_eq!(s, 400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_checks_answer() {
|
||||
let (s, b) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::Validate,
|
||||
true,
|
||||
DEV,
|
||||
None,
|
||||
Some(ANS),
|
||||
);
|
||||
assert_eq!(s, 200);
|
||||
assert_eq!(b, json!({}));
|
||||
let (s, _) = security_question_response(
|
||||
"POST",
|
||||
SecurityAction::Validate,
|
||||
true,
|
||||
DEV,
|
||||
None,
|
||||
Some("nope"),
|
||||
);
|
||||
assert_eq!(s, 400);
|
||||
// wrong method for validate
|
||||
let (s, _) =
|
||||
security_question_response("GET", SecurityAction::Validate, true, DEV, None, Some(ANS));
|
||||
assert_eq!(s, 405);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_sync_defaults_and_core_economy() {
|
||||
// Empty body -> every default applied; persona falls back to the host's.
|
||||
let req = parse_account_sync(b"", 33_068_179);
|
||||
assert_eq!(req.persona_id, 33_068_179);
|
||||
assert_eq!(req.persona_name, "CAGE");
|
||||
assert_eq!(req.level, 1);
|
||||
assert_eq!(req.experience_max, 1000);
|
||||
assert_eq!(req.account_funds_cap, 100_000);
|
||||
let body = account_sync_body(&req, 29_859_876, 2);
|
||||
let acc = &body["account"];
|
||||
assert_eq!(acc["clubName"], "OpenFUT");
|
||||
assert_eq!(acc["clubAbbr"], "OFC");
|
||||
assert_eq!(acc["profilePath"], "accounts/33068179/fifa17_profile.json");
|
||||
assert_eq!(acc["coins"], 29_859_876);
|
||||
assert_eq!(acc["unopenedPacks"], 2);
|
||||
assert_eq!(body["status"], "OK");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_sync_honours_request_overrides() {
|
||||
let req = parse_account_sync(
|
||||
br#"{"personaId":42,"personaName":"X","level":9,"accountFunds":500}"#,
|
||||
33_068_179,
|
||||
);
|
||||
assert_eq!(req.persona_id, 42);
|
||||
assert_eq!(req.persona_name, "X");
|
||||
assert_eq!(req.level, 9);
|
||||
assert_eq!(req.account_funds, 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_mass_info_flat_shape() {
|
||||
let squad = json!({
|
||||
"id": 0,
|
||||
"squadName": "OpenFUT",
|
||||
"formation": "f433",
|
||||
"squadType": "REGULAR_SQUAD",
|
||||
"rating": 90,
|
||||
"chemistry": 49,
|
||||
"actives": [],
|
||||
"players": [],
|
||||
});
|
||||
let body = user_mass_info_body(squad, 29_859_876, 0, 33_068_179);
|
||||
// Flat top-level envelope.
|
||||
assert_eq!(body["pileSizeClientData"]["entries"][0], json!({"key": 2, "value": 100}));
|
||||
assert_eq!(body["pileSizeClientData"]["entries"][1], json!({"key": 4, "value": 50}));
|
||||
assert_eq!(body["settings"], json!({"configs": []}));
|
||||
assert_eq!(body["userData"], json!({}));
|
||||
// userInfo economy + club identity.
|
||||
let ui = &body["userInfo"];
|
||||
assert_eq!(ui["personaId"], 33_068_179);
|
||||
assert_eq!(ui["clubName"], "OpenFUT");
|
||||
assert_eq!(ui["clubAbbr"], "OFC");
|
||||
assert_eq!(ui["established"], "2026"); // string, not number
|
||||
assert_eq!(ui["accountCreatedPlatformName"], "pc");
|
||||
assert_eq!(ui["currencies"][0]["name"], "coins");
|
||||
assert_eq!(ui["currencies"][0]["funds"], 29_859_876);
|
||||
assert_eq!(ui["currencies"][0]["finalFunds"], 29_859_876);
|
||||
assert_eq!(ui["currencies"][1]["name"], "points");
|
||||
assert_eq!(ui["reliability"]["reliability"], 100);
|
||||
assert_eq!(ui["divisionOnline"], 10);
|
||||
assert!(ui.get("unopenedPacks").is_none(), "no packs -> key omitted");
|
||||
assert_eq!(ui["squadList"]["squad"][0]["squadName"], "OpenFUT");
|
||||
// Squad object embedded flat under top-level `squad`.
|
||||
assert_eq!(body["squad"]["squadName"], "OpenFUT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_mass_info_includes_unopened_packs_when_present() {
|
||||
let squad = json!({"id": 0, "actives": [], "players": []});
|
||||
let body = user_mass_info_body(squad, 100, 3, 33_068_179);
|
||||
assert_eq!(body["userInfo"]["unopenedPacks"]["recoveredPacks"], 3);
|
||||
assert_eq!(body["userInfo"]["unopenedPacks"]["preOrderPacks"], 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utc_datetime_formats_known_epochs() {
|
||||
// 2026-08-17 03:54:47 UTC == 1_786_938_887.
|
||||
assert_eq!(format_utc_datetime(1_786_938_887), "2026-08-17 03:54:47");
|
||||
// Unix epoch.
|
||||
assert_eq!(format_utc_datetime(0), "1970-01-01 00:00:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_persona_and_body() {
|
||||
assert_eq!(
|
||||
parse_auth_persona(br#"{"nucleusPersonaId":33068179}"#),
|
||||
Some(33_068_179)
|
||||
);
|
||||
assert_eq!(parse_auth_persona(br#"{"nuc":"42"}"#), Some(42));
|
||||
assert_eq!(parse_auth_persona(b"{}"), None);
|
||||
let b = auth_body("OPENFUT-SID-DEADBEEF", "2026-08-17 03:54:47");
|
||||
assert_eq!(b["protocol"], 1);
|
||||
assert_eq!(b["sid"], "OPENFUT-SID-DEADBEEF");
|
||||
assert_eq!(b["serverTime"], "2026-08-17 03:54:47");
|
||||
assert_eq!(b["lastOnlineTime"], "2026-08-17 03:54:47");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
//! FIFA 17 "My Squad" owned-player search: parse the RS4 `club` query and map
|
||||
//! FIFA 17 wire encodings to the **game-independent** semantic values OpenFUT
|
||||
//! Core understands.
|
||||
//!
|
||||
//! ## The boundary this enforces
|
||||
//!
|
||||
//! The FIFA 17 client sends its owned-player search as query params on
|
||||
//! `GET /ut/game/fifa17/club`, e.g.
|
||||
//! `?year=2017&type=player&count=11&level=gold&position=ST&nation=52&league=13&team=5&sort=desc&start=10`.
|
||||
//! Two encoding families appear: **string enums** (`level`, `rare`, `position`)
|
||||
//! and **numeric FIFA entity ids** (`nation`, `league`, `team`).
|
||||
//!
|
||||
//! **Numeric FIFA ids must never reach Core.** Core filters on semantic names
|
||||
//! ("Premier League", "Chelsea", "Argentina"), so this adapter resolves each id
|
||||
//! to a name via an injected [`EntityResolver`]. An id the resolver cannot map is
|
||||
//! a hard [`MapError`] — never a silent passthrough of the raw number, which is
|
||||
//! exactly how a game-specific id would leak into the generic layer.
|
||||
//!
|
||||
//! ## Evidence-grounded semantics (see vault Protocol Findings / Endpoint Map)
|
||||
//!
|
||||
//! * `level=gold` → semantic quality tier. Grounded in FIFA 17's own convention
|
||||
//! (`fut_cards.py::tier`, gold ≥ 75). `level=any` (the always-present default)
|
||||
//! → no quality constraint.
|
||||
//! * `position` / `nation` / `league` / `team` → applied. The Python oracle
|
||||
//! applied only `league`+`team`; applying the rest is a deliberate correction
|
||||
//! of a proven bug, not a guess (each maps to a card attribute Core already
|
||||
//! stores). FIFA `team` is Core `club`.
|
||||
//! * `start` / `count` → semantic `offset` / `limit`. The oracle ignored both and
|
||||
//! re-served page one forever; Core paginates for real. The client's 11-count /
|
||||
//! 10-step windowing is a UI convention and stays out of Core.
|
||||
//! * `sort=desc` → **dropped**. No sort key was ever proven (the oracle does not
|
||||
//! sort); Core imposes its own deterministic order. We do not invent a named
|
||||
//! FIFA sort mode.
|
||||
//! * `rare=SP` ("Special") → **UNKNOWN and unsupported.** The oracle never reads
|
||||
//! it and no committed metadata grounds "SP" to a card set. It is recorded in
|
||||
//! [`CoreOwnedQuery::unsupported`] and deliberately produces **no** Core filter.
|
||||
//!
|
||||
//! ## Decoupling
|
||||
//!
|
||||
//! This module does not depend on `openfut-core`. The contract between the two is
|
||||
//! the set of Core `/collection` query-parameter *names* emitted by
|
||||
//! [`CoreOwnedQuery::to_query_pairs`]; they mirror Core's `OwnedItemQuery` fields
|
||||
//! and are pinned by a test so drift is caught.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The FIFA 17 club-search query exactly as it arrives on the wire. Numeric
|
||||
/// fields are FIFA entity ids that MUST be resolved before reaching Core.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct Fifa17ClubQuery {
|
||||
/// Quality filter: `any` (default, always present) or `gold`.
|
||||
pub level: Option<String>,
|
||||
/// "Special" filter (`SP`). Semantics UNKNOWN — never applied.
|
||||
pub rare: Option<String>,
|
||||
/// Playing position, e.g. `ST`.
|
||||
pub position: Option<String>,
|
||||
/// FIFA nation id (e.g. 52 = Argentina).
|
||||
pub nation: Option<u32>,
|
||||
/// FIFA league id (e.g. 13 = Premier League).
|
||||
pub league: Option<u32>,
|
||||
/// FIFA team id (e.g. 5 = Chelsea). Core calls this "club".
|
||||
pub team: Option<u32>,
|
||||
/// Client sort token (`desc`). No proven key; dropped.
|
||||
pub sort: Option<String>,
|
||||
/// Pagination offset.
|
||||
pub start: Option<u32>,
|
||||
/// Pagination page size.
|
||||
pub count: Option<u32>,
|
||||
}
|
||||
|
||||
/// Minimal percent/`+` decoding, dependency-free. FIFA sends bare tokens and
|
||||
/// numeric ids, but names in general may be percent-encoded.
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let b = s.as_bytes();
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let hex = |c: u8| (c as char).to_digit(16);
|
||||
let mut i = 0;
|
||||
while i < b.len() {
|
||||
match b[i] {
|
||||
b'+' => {
|
||||
out.push(b' ');
|
||||
i += 1;
|
||||
}
|
||||
b'%' if i + 2 < b.len() => match (hex(b[i + 1]), hex(b[i + 2])) {
|
||||
(Some(hi), Some(lo)) => {
|
||||
out.push((hi * 16 + lo) as u8);
|
||||
i += 3;
|
||||
}
|
||||
_ => {
|
||||
out.push(b'%');
|
||||
i += 1;
|
||||
}
|
||||
},
|
||||
c => {
|
||||
out.push(c);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
/// Parse the raw query string into a [`Fifa17ClubQuery`].
|
||||
///
|
||||
/// Order-independent by construction (each key sets its own field), so HTTP
|
||||
/// parameter order can never change the result. Unknown keys (`year`, `type`, …)
|
||||
/// are ignored. A present-but-unparseable numeric id is treated as absent (the
|
||||
/// retail client never sends one; absent is the safe, non-amplifying choice).
|
||||
pub fn parse_club_query(query: &str) -> Fifa17ClubQuery {
|
||||
let q = query.strip_prefix('?').unwrap_or(query);
|
||||
let mut out = Fifa17ClubQuery::default();
|
||||
for pair in q.split('&').filter(|p| !p.is_empty()) {
|
||||
let (k, v) = match pair.split_once('=') {
|
||||
Some((k, v)) => (k, percent_decode(v)),
|
||||
None => (pair, String::new()),
|
||||
};
|
||||
match k {
|
||||
"level" => out.level = Some(v),
|
||||
"rare" => out.rare = Some(v),
|
||||
"position" => out.position = Some(v),
|
||||
"nation" => out.nation = v.parse().ok(),
|
||||
"league" => out.league = v.parse().ok(),
|
||||
"team" => out.team = v.parse().ok(),
|
||||
"sort" => out.sort = Some(v),
|
||||
"start" => out.start = v.parse().ok(),
|
||||
"count" => out.count = v.parse().ok(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Resolves FIFA 17 numeric entity ids to their semantic names. A real
|
||||
/// implementation reads the game's `leagues`/`teams`/`nations` tables; tests use
|
||||
/// [`StaticResolver`]. Returning `None` means "unknown id" and is fatal, by
|
||||
/// design — the raw id must not flow onward.
|
||||
pub trait EntityResolver {
|
||||
fn league_name(&self, id: u32) -> Option<String>;
|
||||
fn nation_name(&self, id: u32) -> Option<String>;
|
||||
fn team_name(&self, id: u32) -> Option<String>;
|
||||
}
|
||||
|
||||
/// A map-backed [`EntityResolver`] for tests and small deployments.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct StaticResolver {
|
||||
pub leagues: HashMap<u32, String>,
|
||||
pub nations: HashMap<u32, String>,
|
||||
pub teams: HashMap<u32, String>,
|
||||
}
|
||||
|
||||
impl EntityResolver for StaticResolver {
|
||||
fn league_name(&self, id: u32) -> Option<String> {
|
||||
self.leagues.get(&id).cloned()
|
||||
}
|
||||
fn nation_name(&self, id: u32) -> Option<String> {
|
||||
self.nations.get(&id).cloned()
|
||||
}
|
||||
fn team_name(&self, id: u32) -> Option<String> {
|
||||
self.teams.get(&id).cloned()
|
||||
}
|
||||
}
|
||||
|
||||
/// A FIFA id that no resolver could map. Fatal on purpose: never fall back to
|
||||
/// the raw id.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum MapError {
|
||||
UnknownLeague(u32),
|
||||
UnknownNation(u32),
|
||||
UnknownTeam(u32),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for MapError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
MapError::UnknownLeague(id) => write!(f, "unknown FIFA league id {id}"),
|
||||
MapError::UnknownNation(id) => write!(f, "unknown FIFA nation id {id}"),
|
||||
MapError::UnknownTeam(id) => write!(f, "unknown FIFA team id {id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for MapError {}
|
||||
|
||||
/// The semantic query handed to OpenFUT Core. Contains only game-independent
|
||||
/// values: a quality tier string, entity **names**, and semantic offset/limit.
|
||||
/// No FIFA ids.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct CoreOwnedQuery {
|
||||
pub quality: Option<String>,
|
||||
pub position: Option<String>,
|
||||
pub nation: Option<String>,
|
||||
pub league: Option<String>,
|
||||
pub club: Option<String>,
|
||||
pub offset: Option<i64>,
|
||||
pub limit: Option<i64>,
|
||||
/// "Special" filter (`rare=SP`): keep only special cards. Applied by the
|
||||
/// HOST via the FIFA `rareflag` (which lives in the catalog, not Core) — so
|
||||
/// it is NEVER a Core `/collection` param. See [`is_special_rareflag`].
|
||||
pub special: bool,
|
||||
/// Wire filters that were parsed but deliberately NOT applied because their
|
||||
/// semantics are unproven (currently: `rare`/Special). Recorded, never guessed.
|
||||
pub unsupported: Vec<&'static str>,
|
||||
}
|
||||
|
||||
impl CoreOwnedQuery {
|
||||
/// Core `/collection` query parameters. Param **names mirror**
|
||||
/// `openfut_core::services::inventory::OwnedItemQuery` and are the wire
|
||||
/// contract between this adapter and Core (pinned by test). `unsupported`
|
||||
/// filters are intentionally absent.
|
||||
pub fn to_query_pairs(&self) -> Vec<(&'static str, String)> {
|
||||
let mut p = Vec::new();
|
||||
if let Some(q) = &self.quality {
|
||||
p.push(("quality", q.clone()));
|
||||
}
|
||||
if let Some(x) = &self.position {
|
||||
p.push(("position", x.clone()));
|
||||
}
|
||||
if let Some(x) = &self.nation {
|
||||
p.push(("nation", x.clone()));
|
||||
}
|
||||
if let Some(x) = &self.league {
|
||||
p.push(("league", x.clone()));
|
||||
}
|
||||
if let Some(x) = &self.club {
|
||||
p.push(("club", x.clone()));
|
||||
}
|
||||
if let Some(x) = self.offset {
|
||||
p.push(("offset", x.to_string()));
|
||||
}
|
||||
if let Some(x) = self.limit {
|
||||
p.push(("limit", x.to_string()));
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a parsed FIFA 17 query to the semantic Core query, resolving every
|
||||
/// numeric id to a name. Any unknown id is a hard error — the raw id never flows
|
||||
/// through.
|
||||
pub fn map_to_core(
|
||||
q: &Fifa17ClubQuery,
|
||||
resolver: &impl EntityResolver,
|
||||
) -> Result<CoreOwnedQuery, MapError> {
|
||||
// level: only the proven quality tiers map; "any"/absent → no constraint.
|
||||
let quality = match q.level.as_deref() {
|
||||
Some("gold") => Some("gold".to_string()),
|
||||
Some("silver") => Some("silver".to_string()),
|
||||
Some("bronze") => Some("bronze".to_string()),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
// rare=SP → "Special" quality. Now GROUNDED via the observed FIFA rareflag
|
||||
// (carried in the catalog): a special is rareflag > 1 (base rare = 1). The
|
||||
// host applies it post-shape; Core never sees it. Any OTHER `rare` value
|
||||
// stays genuinely unsupported (recorded, never guessed).
|
||||
let special = matches!(q.rare.as_deref(), Some(s) if s.eq_ignore_ascii_case("SP"));
|
||||
let mut unsupported = Vec::new();
|
||||
if q.rare.is_some() && !special {
|
||||
unsupported.push("rare");
|
||||
}
|
||||
|
||||
let position = q.position.as_ref().map(|p| p.to_uppercase());
|
||||
|
||||
let nation = match q.nation {
|
||||
Some(id) => Some(
|
||||
resolver
|
||||
.nation_name(id)
|
||||
.ok_or(MapError::UnknownNation(id))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let league = match q.league {
|
||||
Some(id) => Some(
|
||||
resolver
|
||||
.league_name(id)
|
||||
.ok_or(MapError::UnknownLeague(id))?,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
// FIFA "team" is Core "club".
|
||||
let club = match q.team {
|
||||
Some(id) => Some(resolver.team_name(id).ok_or(MapError::UnknownTeam(id))?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
Ok(CoreOwnedQuery {
|
||||
quality,
|
||||
position,
|
||||
nation,
|
||||
league,
|
||||
club,
|
||||
offset: q.start.map(|s| s as i64),
|
||||
limit: q.count.map(|c| c as i64),
|
||||
special,
|
||||
unsupported,
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a FIFA `rareflag` denotes a SPECIAL card (in-form/programme), as
|
||||
/// opposed to a base card. Grounded in the observed profile + the FIFA 17
|
||||
/// taxonomy: 0 = common, 1 = rare (both BASE gold/silver/bronze); every value
|
||||
/// above 1 is a special programme (3 = TOTW, 21..=24 = programmes, etc.).
|
||||
pub fn is_special_rareflag(rareflag: i64) -> bool {
|
||||
rareflag > 1
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn resolver() -> StaticResolver {
|
||||
// Confirmed against fifa17-recon/data/tables/{leagues,teams,nations}.json.
|
||||
StaticResolver {
|
||||
leagues: HashMap::from([(13, "Premier League".to_string())]),
|
||||
nations: HashMap::from([(52, "Argentina".to_string())]),
|
||||
teams: HashMap::from([(5, "Chelsea".to_string())]),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_full_query() {
|
||||
let q = parse_club_query(
|
||||
"year=2017&type=player&count=11&level=gold&position=ST&nation=52&league=13&team=5&sort=desc&start=10",
|
||||
);
|
||||
assert_eq!(
|
||||
q,
|
||||
Fifa17ClubQuery {
|
||||
level: Some("gold".into()),
|
||||
rare: None,
|
||||
position: Some("ST".into()),
|
||||
nation: Some(52),
|
||||
league: Some(13),
|
||||
team: Some(5),
|
||||
sort: Some("desc".into()),
|
||||
start: Some(10),
|
||||
count: Some(11),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_is_parameter_order_independent() {
|
||||
let a = parse_club_query("level=gold&league=13&position=ST&start=10&count=11");
|
||||
let b = parse_club_query("count=11&start=10&position=ST&league=13&level=gold");
|
||||
assert_eq!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_ignores_unknown_keys_and_omitted_optionals() {
|
||||
let q = parse_club_query("year=2017&type=player&level=any&sort=desc");
|
||||
assert_eq!(q.level.as_deref(), Some("any"));
|
||||
assert!(q.rare.is_none() && q.position.is_none() && q.nation.is_none());
|
||||
assert!(q.league.is_none() && q.team.is_none() && q.start.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_percent_encoded_value() {
|
||||
let q = parse_club_query("position=ST&rare=SP");
|
||||
assert_eq!(q.position.as_deref(), Some("ST"));
|
||||
assert_eq!(q.rare.as_deref(), Some("SP"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_malformed_numeric_is_absent() {
|
||||
let q = parse_club_query("league=notanumber");
|
||||
assert!(
|
||||
q.league.is_none(),
|
||||
"malformed id treated as absent, not applied"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_level_gold_to_quality() {
|
||||
let core = map_to_core(&parse_club_query("level=gold"), &resolver()).unwrap();
|
||||
assert_eq!(core.quality.as_deref(), Some("gold"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_level_any_has_no_quality_filter() {
|
||||
let core = map_to_core(&parse_club_query("level=any"), &resolver()).unwrap();
|
||||
assert_eq!(core.quality, None, "'any' must NOT become a quality filter");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_rare_sp_sets_special_and_never_a_core_param() {
|
||||
let core = map_to_core(&parse_club_query("level=any&rare=SP"), &resolver()).unwrap();
|
||||
// rare=SP is now GROUNDED: a host-applied special flag, not "unsupported".
|
||||
assert!(core.special, "rare=SP must set the special flag");
|
||||
assert!(!core.unsupported.contains(&"rare"));
|
||||
// still NEVER a Core predicate (Core has no rareflag) and no quality guess.
|
||||
assert_eq!(core.quality, None);
|
||||
let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect();
|
||||
assert!(!keys.contains(&"rare") && !keys.contains(&"special"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_unknown_rare_value_stays_unsupported() {
|
||||
let core = map_to_core(&parse_club_query("rare=WAT"), &resolver()).unwrap();
|
||||
assert!(!core.special);
|
||||
assert!(core.unsupported.contains(&"rare"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_predicate_base_vs_special() {
|
||||
assert!(!is_special_rareflag(0)); // common
|
||||
assert!(!is_special_rareflag(1)); // rare gold (base)
|
||||
assert!(is_special_rareflag(3)); // TOTW
|
||||
assert!(is_special_rareflag(24)); // programme
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_resolves_ids_to_semantic_names() {
|
||||
let core =
|
||||
map_to_core(&parse_club_query("nation=52&league=13&team=5"), &resolver()).unwrap();
|
||||
assert_eq!(core.nation.as_deref(), Some("Argentina"));
|
||||
assert_eq!(core.league.as_deref(), Some("Premier League"));
|
||||
assert_eq!(
|
||||
core.club.as_deref(),
|
||||
Some("Chelsea"),
|
||||
"FIFA team -> Core club"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_unknown_id_is_a_hard_error_not_passthrough() {
|
||||
assert_eq!(
|
||||
map_to_core(&parse_club_query("league=9999"), &resolver()),
|
||||
Err(MapError::UnknownLeague(9999))
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_core(&parse_club_query("nation=9999"), &resolver()),
|
||||
Err(MapError::UnknownNation(9999))
|
||||
);
|
||||
assert_eq!(
|
||||
map_to_core(&parse_club_query("team=9999"), &resolver()),
|
||||
Err(MapError::UnknownTeam(9999))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_raw_fifa_id_ever_reaches_core() {
|
||||
// Every resolvable id becomes a name; a numeric string must never appear
|
||||
// as a nation/league/club value in the Core-bound pairs.
|
||||
let core =
|
||||
map_to_core(&parse_club_query("nation=52&league=13&team=5"), &resolver()).unwrap();
|
||||
for (k, v) in core.to_query_pairs() {
|
||||
if matches!(k, "nation" | "league" | "club") {
|
||||
assert!(
|
||||
v.parse::<u32>().is_err(),
|
||||
"{k}={v} looks like a raw FIFA id leaking into Core"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_start_count_to_offset_limit() {
|
||||
let core = map_to_core(&parse_club_query("start=20&count=11"), &resolver()).unwrap();
|
||||
assert_eq!(core.offset, Some(20));
|
||||
assert_eq!(core.limit, Some(11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn map_position_uppercased() {
|
||||
let core = map_to_core(&parse_club_query("position=st"), &resolver()).unwrap();
|
||||
assert_eq!(core.position.as_deref(), Some("ST"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sort_is_dropped_no_core_param() {
|
||||
let core = map_to_core(&parse_club_query("sort=desc"), &resolver()).unwrap();
|
||||
let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect();
|
||||
assert!(
|
||||
!keys.contains(&"sort"),
|
||||
"no proven FIFA sort key; must not emit one"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn core_query_param_names_mirror_core_contract() {
|
||||
// Pins the wire contract with openfut-core's OwnedItemQuery field names.
|
||||
let core = CoreOwnedQuery {
|
||||
quality: Some("gold".into()),
|
||||
position: Some("ST".into()),
|
||||
nation: Some("Argentina".into()),
|
||||
league: Some("Premier League".into()),
|
||||
club: Some("Chelsea".into()),
|
||||
offset: Some(10),
|
||||
limit: Some(11),
|
||||
special: false,
|
||||
unsupported: vec![],
|
||||
};
|
||||
let keys: Vec<&str> = core.to_query_pairs().into_iter().map(|(k, _)| k).collect();
|
||||
assert_eq!(
|
||||
keys,
|
||||
["quality", "position", "nation", "league", "club", "offset", "limit"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn end_to_end_capture_shaped_query() {
|
||||
// Mirrors the retail PAGINATION capture: PL + Chelsea, page 2.
|
||||
let core = map_to_core(
|
||||
&parse_club_query(
|
||||
"year=2017&type=player&count=11&level=gold&nation=52&league=13&team=5&sort=desc&start=10",
|
||||
),
|
||||
&resolver(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
core,
|
||||
CoreOwnedQuery {
|
||||
quality: Some("gold".into()),
|
||||
position: None,
|
||||
nation: Some("Argentina".into()),
|
||||
league: Some("Premier League".into()),
|
||||
club: Some("Chelsea".into()),
|
||||
offset: Some(10),
|
||||
limit: Some(11),
|
||||
special: false,
|
||||
unsupported: vec![],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
//! FIFA 17 Store pack-content generator (pure, seeded).
|
||||
//!
|
||||
//! Draws the cards a Store pack awards. It is a **pure function** of
|
||||
//! `(pack definition, RNG, candidate pool)` — no IO, no Core, no catalogue
|
||||
//! lookup — so it is deterministic under a seeded [`rand::Rng`] and trivially
|
||||
//! unit-tested. The host owns the impure parts: it builds the candidate pool
|
||||
//! (only card ids that resolve in BOTH the FIFA catalogue and Core content),
|
||||
//! mints the drawn cards into Core, and shapes them onto the wire.
|
||||
//!
|
||||
//! ## Parity note — Python `open_pack` / `_pack_body`
|
||||
//! (`fifa17-recon/tools/fut_store.py:689`, `utas_server.py:3474`)
|
||||
//! 1. `open_pack(price, count, gold, tiers, special_chance)` deducts coins then
|
||||
//! draws `count` items (mostly players); the reveal body wraps them verbatim.
|
||||
//! 2. Non-tiered draws split the pool at rating 75 by `gold` (`p[1] >= 75 == gold`)
|
||||
//! and fall back to the whole pool when that tier is empty (`... or PACK_POOL`).
|
||||
//! 3. Each drawn player becomes a special with probability `special_chance`
|
||||
//! (`random.random() < special_chance`).
|
||||
//! 4. `FUT_PACK_MIX` swaps ~`count // 4` players for consumables/staff extras;
|
||||
//! we deliberately OMIT that mix (Core candidates are player defs — players-only).
|
||||
//! 5. Prices/counts/odds are the OpenFUT **PLACEHOLDER** economy (the audit found
|
||||
//! them invented); only the wire *shape* is EA-observed/oracle-verified.
|
||||
//! 6. This port reproduces the count + gold-tier split + `special_chance` gate as
|
||||
//! that same PLACEHOLDER policy, drawing with replacement from the pool.
|
||||
|
||||
use rand::Rng;
|
||||
|
||||
use crate::fut::store_catalog::PackDef;
|
||||
|
||||
/// A candidate the host has already verified resolves in BOTH the FIFA catalogue
|
||||
/// and Core content. Carries the full Core definition the shaper needs plus the
|
||||
/// two draw-policy annotations (`gold` tier, `special` version) the host derives
|
||||
/// from the catalogue (keeping this generator pure — it never reads a catalogue).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GeneratedCandidate {
|
||||
/// Core card-definition id (resolves in the FIFA catalogue and Core content).
|
||||
pub card_id: String,
|
||||
pub rating: u8,
|
||||
pub position: String,
|
||||
pub nation: String,
|
||||
pub league: String,
|
||||
pub club: String,
|
||||
/// [pace, shooting, passing, dribbling, defending, physical].
|
||||
pub attributes: [u8; 6],
|
||||
/// Gold tier (host derives this as `rating >= 75`, the oracle's split point).
|
||||
pub gold: bool,
|
||||
/// Special version available (host derives this from the catalogue rareflag
|
||||
/// `> 1`); gated by [`PackDef::special_chance`].
|
||||
pub special: bool,
|
||||
}
|
||||
|
||||
impl GeneratedCandidate {
|
||||
fn to_card(&self) -> GeneratedCard {
|
||||
GeneratedCard {
|
||||
card_id: self.card_id.clone(),
|
||||
rating: self.rating,
|
||||
position: self.position.clone(),
|
||||
nation: self.nation.clone(),
|
||||
league: self.league.clone(),
|
||||
club: self.club.clone(),
|
||||
attributes: self.attributes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One card a pack awarded. Carries `card_id` (the Core definition the host mints
|
||||
/// and shapes) plus the definition fields the shared item shaper needs. It is
|
||||
/// NOT an owned instance yet — the host mints the Core instance id and allocates
|
||||
/// the numeric wire id.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GeneratedCard {
|
||||
pub card_id: String,
|
||||
pub rating: u8,
|
||||
pub position: String,
|
||||
pub nation: String,
|
||||
pub league: String,
|
||||
pub club: String,
|
||||
pub attributes: [u8; 6],
|
||||
}
|
||||
|
||||
/// Draw `pack.count` cards from `pool` with the injected RNG. Pure and
|
||||
/// deterministic under a seeded RNG. Returns an empty `Vec` (fail-closed) when
|
||||
/// the pool is empty or the pack awards no cards.
|
||||
///
|
||||
/// Policy (PLACEHOLDER — see the module parity note): draw with replacement from
|
||||
/// the pack's tier (`gold`), biasing each draw toward a special card with
|
||||
/// probability `special_chance`. An empty tier or partition falls back to the
|
||||
/// next-wider set so a draw is always possible when the pool is non-empty.
|
||||
pub fn generate_pack_contents(
|
||||
pack: &PackDef,
|
||||
rng: &mut impl Rng,
|
||||
pool: &[GeneratedCandidate],
|
||||
) -> Vec<GeneratedCard> {
|
||||
if pool.is_empty() || pack.count == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
// Tier split: a gold pack draws gold-tier candidates, a non-gold pack draws
|
||||
// non-gold; an empty tier falls back to the whole pool (oracle `... or POOL`).
|
||||
let tier: Vec<&GeneratedCandidate> = pool.iter().filter(|c| c.gold == pack.gold).collect();
|
||||
let tier: Vec<&GeneratedCandidate> = if tier.is_empty() {
|
||||
pool.iter().collect()
|
||||
} else {
|
||||
tier
|
||||
};
|
||||
// Partition the tier by special so `special_chance` can bias a draw; either
|
||||
// partition falls back to the whole tier when empty.
|
||||
let special: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| c.special).collect();
|
||||
let normal: Vec<&GeneratedCandidate> = tier.iter().copied().filter(|c| !c.special).collect();
|
||||
let chance = pack.special_chance.clamp(0.0, 1.0);
|
||||
|
||||
let mut out = Vec::with_capacity(pack.count as usize);
|
||||
for _ in 0..pack.count {
|
||||
let want_special = chance > 0.0 && rng.gen_bool(chance);
|
||||
let sub: &[&GeneratedCandidate] = if want_special && !special.is_empty() {
|
||||
&special
|
||||
} else if !want_special && !normal.is_empty() {
|
||||
&normal
|
||||
} else {
|
||||
&tier
|
||||
};
|
||||
let pick = sub[rng.gen_range(0..sub.len())];
|
||||
out.push(pick.to_card());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rand::rngs::StdRng;
|
||||
use rand::SeedableRng;
|
||||
|
||||
fn cand(card: &str, rating: u8, gold: bool, special: bool) -> GeneratedCandidate {
|
||||
GeneratedCandidate {
|
||||
card_id: card.into(),
|
||||
rating,
|
||||
position: "ST".into(),
|
||||
nation: "Brazil".into(),
|
||||
league: "Premier League".into(),
|
||||
club: "Arsenal".into(),
|
||||
attributes: [rating; 6],
|
||||
gold,
|
||||
special,
|
||||
}
|
||||
}
|
||||
|
||||
/// A mixed pool: gold specials, gold normals, and a bronze tier.
|
||||
fn pool() -> Vec<GeneratedCandidate> {
|
||||
vec![
|
||||
cand("g-sp-1", 90, true, true),
|
||||
cand("g-sp-2", 88, true, true),
|
||||
cand("g-1", 84, true, false),
|
||||
cand("g-2", 82, true, false),
|
||||
cand("g-3", 79, true, false),
|
||||
cand("b-1", 64, false, false),
|
||||
cand("b-2", 62, false, false),
|
||||
]
|
||||
}
|
||||
|
||||
fn pack(id: u64, count: u64, gold: bool, special_chance: f64) -> PackDef {
|
||||
PackDef {
|
||||
id,
|
||||
name: "Test Pack",
|
||||
price: 1000,
|
||||
count,
|
||||
gold,
|
||||
special_chance,
|
||||
owned_only: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_seed_same_output() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 7, true, 0.3);
|
||||
let mut a = StdRng::seed_from_u64(42);
|
||||
let mut b = StdRng::seed_from_u64(42);
|
||||
assert_eq!(
|
||||
generate_pack_contents(&p, &mut a, &pool),
|
||||
generate_pack_contents(&p, &mut b, &pool)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn different_seeds_can_diverge() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 7, true, 0.3);
|
||||
let a = generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &pool);
|
||||
let b = generate_pack_contents(&p, &mut StdRng::seed_from_u64(999), &pool);
|
||||
// Not a hard guarantee, but with this pool/count the two seeds differ.
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_is_exact_and_all_cards_from_pool() {
|
||||
let pool = pool();
|
||||
let ids: std::collections::HashSet<&str> =
|
||||
pool.iter().map(|c| c.card_id.as_str()).collect();
|
||||
for &n in &[1u64, 5, 7, 11] {
|
||||
let p = pack(6, n, true, 0.08);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(n), &pool);
|
||||
assert_eq!(cards.len() as u64, n);
|
||||
for c in &cards {
|
||||
assert!(
|
||||
ids.contains(c.card_id.as_str()),
|
||||
"drew unknown card {}",
|
||||
c.card_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gold_pack_draws_only_gold_tier() {
|
||||
let pool = pool();
|
||||
let p = pack(5, 20, true, 0.03);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
|
||||
assert!(
|
||||
cards.iter().all(|c| c.rating >= 75),
|
||||
"gold pack drew a bronze card"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bronze_pack_draws_only_bronze_tier() {
|
||||
let pool = pool();
|
||||
let p = pack(1, 20, false, 0.005);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(7), &pool);
|
||||
assert!(
|
||||
cards.iter().all(|c| c.rating < 75),
|
||||
"bronze pack drew a gold card"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_chance_one_draws_only_specials() {
|
||||
let pool = pool();
|
||||
let special_ids: std::collections::HashSet<&str> = pool
|
||||
.iter()
|
||||
.filter(|c| c.special)
|
||||
.map(|c| c.card_id.as_str())
|
||||
.collect();
|
||||
let p = pack(7, 11, true, 1.0);
|
||||
let cards = generate_pack_contents(&p, &mut StdRng::seed_from_u64(3), &pool);
|
||||
assert!(cards
|
||||
.iter()
|
||||
.all(|c| special_ids.contains(c.card_id.as_str())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pool_fails_closed() {
|
||||
let p = pack(5, 7, true, 0.03);
|
||||
assert!(generate_pack_contents(&p, &mut StdRng::seed_from_u64(1), &[]).is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
//! FIFA 17 squad **full-replacement** wire (`PUT /ut/game/fifa17/squad/<id>`) →
|
||||
//! a game-independent proposed replacement OpenFUT Core can apply.
|
||||
//!
|
||||
//! ## Scope (deliberately preparatory — nothing is routed yet)
|
||||
//!
|
||||
//! This module parses the captured squad-save wire and reverse-maps each slot's
|
||||
//! FIFA wire item id to a Core owned-instance id, producing a [`ProposedSquad`].
|
||||
//! It does **not** open a socket, call Core, or mutate state — squad is a
|
||||
//! *stateful* slice and its GET (retrieval) and PUT (save) must migrate together
|
||||
//! as one authority, which needs more captured evidence first. Until then this is
|
||||
//! pure, unit-tested scaffolding.
|
||||
//!
|
||||
//! ## What the wire proves (2 captured retail saves, `fixtures/utas/`)
|
||||
//!
|
||||
//! * The client sends the **whole** squad on every save — a fixed 23-slot array
|
||||
//! plus `formation`, `captain`, `kicktakers`, a 33-int `custom` string, and
|
||||
//! client-reported `chemistry`/`rating`/`starRating`. A two-player edit changed
|
||||
//! nine slots, so slot deltas never describe intent: the only honest operation
|
||||
//! is *this is the squad now*.
|
||||
//! * Each occupied slot carries its FIFA **wire item id** (`itemData.id`, the same
|
||||
//! namespace as `/club` and the identity store); an **empty** slot is `id == 0`.
|
||||
//! * `captain` and `kicktakers` reference the same wire item id space.
|
||||
//!
|
||||
//! ## What it does NOT prove (kept UNKNOWN, never invented)
|
||||
//!
|
||||
//! * The `index → (slot, bench)` layout for formations other than **f442**, and
|
||||
//! the meaning/stability of the `custom` 33-int array. `custom` is preserved
|
||||
//! **opaquely** so it can round-trip unchanged; its integers are not decoded.
|
||||
//! * FIFA's chemistry/rating algorithm — client-reported values are carried in
|
||||
//! [`ClientReportedSquadEval`] and never reconciled with Core's own evaluation.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A `{ "id": <wire item id>, "dream": bool }` reference (player, manager, …).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SquadEntityRef {
|
||||
pub id: i64,
|
||||
#[serde(default)]
|
||||
pub dream: bool,
|
||||
}
|
||||
|
||||
/// One entry of the fixed-length `players` array.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SquadSlotWire {
|
||||
pub index: i64,
|
||||
pub item_data: SquadEntityRef,
|
||||
#[serde(default)]
|
||||
pub kit_number: i64,
|
||||
}
|
||||
|
||||
/// A `kicktakers` entry (penalty/corner/free-kick roles). Carries a wire item id;
|
||||
/// role semantics are UNKNOWN and not modelled.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SquadKicktaker {
|
||||
pub index: i64,
|
||||
pub id: i64,
|
||||
#[serde(default)]
|
||||
pub dream: bool,
|
||||
}
|
||||
|
||||
/// The squad-save body exactly as FIFA 17 sends it. FIFA-only fields
|
||||
/// (`custom`, `kicktakers`, `kit_number`, `manager`, `squad_type`) are captured
|
||||
/// verbatim; their persistence/reconstruction rules await more evidence.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Fifa17SquadPut {
|
||||
/// Squad id (the path `…/squad/0` and the body agree; `0` = active).
|
||||
#[serde(default)]
|
||||
pub id: i64,
|
||||
#[serde(default)]
|
||||
pub squad_name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub formation: Option<String>,
|
||||
#[serde(default)]
|
||||
pub squad_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub chemistry: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub rating: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub star_rating: Option<i64>,
|
||||
/// Wire item id of the captain (must be one of the occupied slots).
|
||||
#[serde(default)]
|
||||
pub captain: Option<i64>,
|
||||
/// Opaque 33-int array as a JSON-encoded string. Semantics UNKNOWN — carried
|
||||
/// verbatim, never parsed or interpreted.
|
||||
#[serde(default)]
|
||||
pub custom: Option<String>,
|
||||
#[serde(default)]
|
||||
pub manager: Vec<SquadEntityRef>,
|
||||
#[serde(default)]
|
||||
pub players: Vec<SquadSlotWire>,
|
||||
#[serde(default)]
|
||||
pub kicktakers: Vec<SquadKicktaker>,
|
||||
}
|
||||
|
||||
/// Reverse-maps a FIFA wire item id to a Core owned-instance id. Implemented by
|
||||
/// the host over `Fifa17IdentityResolver`; `None` = unknown id (never guessed).
|
||||
pub trait SquadWireResolver {
|
||||
fn owned_id_for_wire(&self, wire: i64) -> Option<String>;
|
||||
}
|
||||
|
||||
/// Client-reported squad evaluation. Kept DISTINCT from Core's authoritative
|
||||
/// evaluation and never reconciled — FIFA's chemistry/rating algorithm is UNKNOWN.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ClientReportedSquadEval {
|
||||
pub chemistry: Option<i64>,
|
||||
pub rating: Option<i64>,
|
||||
pub star_rating: Option<i64>,
|
||||
}
|
||||
|
||||
/// One resolved slot of a proposed replacement (game-independent).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ProposedSlot {
|
||||
pub owned_card_id: String,
|
||||
/// FIFA player-array index (0-based). Core imposes its own slot numbering;
|
||||
/// the adapter carries the index plus a bench flag from the f442 convention.
|
||||
pub index: i64,
|
||||
pub kit_number: i64,
|
||||
pub is_captain: bool,
|
||||
pub is_on_bench: bool,
|
||||
}
|
||||
|
||||
/// A full-squad replacement in **canonical** (game-independent) terms, ready for
|
||||
/// the host to map onto Core's `SquadReplacement`/`SaveSquadRequest`. Carries no
|
||||
/// FIFA-only state (that is [`crate::fut::squad_ext::Fifa17SquadExtensionV1`])
|
||||
/// and no `openfut-core` dependency. No FIFA wire integer survives into a slot —
|
||||
/// every player is a Core `owned_card_id`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProposedSquad {
|
||||
/// The FIFA wire squad id the PUT targeted (`0` = the active squad). Routing
|
||||
/// only — it selects which Core squad to replace; it is never a Core field.
|
||||
pub squad_id: i64,
|
||||
pub name: Option<String>,
|
||||
/// The FIFA formation token exactly as sent (e.g. `"f442"`, `"f433"`). Core
|
||||
/// stores it verbatim as its opaque formation token and never interprets it,
|
||||
/// so it round-trips exactly — never mapped to a second representation and
|
||||
/// never used to derive slot layout.
|
||||
pub formation: Option<String>,
|
||||
pub slots: Vec<ProposedSlot>,
|
||||
/// Occupied wire item ids the resolver could not map. A caller MUST refuse the
|
||||
/// replacement if this is non-empty — a save must never silently drop an
|
||||
/// owned player it failed to identify.
|
||||
pub unresolved_wire_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
/// Parse errors — explicit, never a silent empty squad.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum SquadError {
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SquadError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SquadError::Parse(e) => write!(f, "squad wire parse error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for SquadError {}
|
||||
|
||||
/// The FIFA 17 squad wire is a fixed 23-slot array: indices `0..=10` are the
|
||||
/// pitch (the 11 starters), `11..=22` are bench/reserves. This layout is a
|
||||
/// property of the array, not of the formation — the captured f442 and f433
|
||||
/// saves both place their 11 starters at `0..=10`. Bench membership is therefore
|
||||
/// derived from the index alone, NEVER from the formation token.
|
||||
pub const FIFA17_STARTER_SLOTS: i64 = 11;
|
||||
|
||||
/// Length of the fixed FIFA 17 squad slot array (evidence: every captured save
|
||||
/// and read carries exactly 23 slots).
|
||||
pub const FIFA17_SQUAD_SLOTS: i64 = 23;
|
||||
|
||||
/// Parse a squad-save body into the typed wire form. Structural only.
|
||||
pub fn parse_squad_put(body: &[u8]) -> Result<Fifa17SquadPut, SquadError> {
|
||||
serde_json::from_slice(body).map_err(|e| SquadError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
/// Resolve a parsed save into a **canonical** [`ProposedSquad`]: drop empty
|
||||
/// (`id == 0`) slots, reverse-map each occupied slot's wire id to a Core
|
||||
/// `owned_card_id`, flag the captain, and derive the bench split from the fixed
|
||||
/// 23-slot array. FIFA-only state (`custom`, manager, kicktakers, kit numbers,
|
||||
/// squadType) and client-reported evaluation are NOT canonical — they are built
|
||||
/// separately into [`crate::fut::squad_ext::Fifa17SquadExtensionV1`]. The
|
||||
/// formation token is carried verbatim (never mapped). Unresolvable occupied ids
|
||||
/// are reported, never guessed or dropped.
|
||||
pub fn to_proposed(put: &Fifa17SquadPut, resolver: &dyn SquadWireResolver) -> ProposedSquad {
|
||||
let captain = put.captain.unwrap_or(0);
|
||||
let mut slots = Vec::new();
|
||||
let mut unresolved = Vec::new();
|
||||
for p in &put.players {
|
||||
if p.item_data.id == 0 {
|
||||
continue; // empty slot — never a Core player
|
||||
}
|
||||
match resolver.owned_id_for_wire(p.item_data.id) {
|
||||
Some(owned_card_id) => slots.push(ProposedSlot {
|
||||
owned_card_id,
|
||||
index: p.index,
|
||||
kit_number: p.kit_number,
|
||||
is_captain: captain != 0 && p.item_data.id == captain,
|
||||
is_on_bench: p.index >= FIFA17_STARTER_SLOTS,
|
||||
}),
|
||||
None => unresolved.push(p.item_data.id),
|
||||
}
|
||||
}
|
||||
ProposedSquad {
|
||||
squad_id: put.id,
|
||||
name: put.squad_name.clone(),
|
||||
formation: put.formation.clone(),
|
||||
slots,
|
||||
unresolved_wire_ids: unresolved,
|
||||
}
|
||||
}
|
||||
|
||||
/// The save acknowledgement FIFA expects: just the squad id (matches the oracle's
|
||||
/// `{"id": <n>}`, 9 bytes — it does NOT echo the squad).
|
||||
pub fn save_ack(squad_id: i64) -> serde_json::Value {
|
||||
serde_json::json!({ "id": squad_id })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// The real captured f442 save body (decoded from `session-001.jsonl:21`).
|
||||
const PUT_F442: &str = include_str!("../../fixtures/utas/squad_put_f442.json");
|
||||
|
||||
/// A resolver mapping every occupied wire id in the fixture to a Core id.
|
||||
struct MapResolver(HashMap<i64, String>);
|
||||
impl SquadWireResolver for MapResolver {
|
||||
fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
|
||||
self.0.get(&wire).cloned()
|
||||
}
|
||||
}
|
||||
fn full_resolver() -> MapResolver {
|
||||
// indices 0..=10 (11 starters); the rest of the 23 slots are id==0 (empty).
|
||||
let ids = [
|
||||
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
|
||||
100000001, 100000002, 100000025,
|
||||
];
|
||||
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_captured_full_squad_wire() {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
assert_eq!(put.id, 0, "path/body squad id 0 = active");
|
||||
assert_eq!(put.formation.as_deref(), Some("f442"));
|
||||
assert_eq!(put.squad_type.as_deref(), Some("REGULAR_SQUAD"));
|
||||
assert_eq!(put.chemistry, Some(52));
|
||||
assert_eq!(put.rating, Some(90));
|
||||
assert_eq!(put.star_rating, Some(90));
|
||||
assert_eq!(put.captain, Some(100000001));
|
||||
assert_eq!(put.players.len(), 23, "fixed 23-slot array");
|
||||
assert_eq!(put.kicktakers.len(), 5);
|
||||
// custom is carried opaquely, never parsed.
|
||||
assert!(put.custom.as_deref().unwrap().starts_with("[0,0,0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_occupied_slots_drops_empties_and_flags_captain() {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
let sq = to_proposed(&put, &full_resolver());
|
||||
|
||||
assert_eq!(sq.slots.len(), 11, "11 occupied; 12 empty (id==0) dropped");
|
||||
assert!(
|
||||
sq.unresolved_wire_ids.is_empty(),
|
||||
"all occupied ids resolved"
|
||||
);
|
||||
assert_eq!(
|
||||
sq.formation.as_deref(),
|
||||
Some("f442"),
|
||||
"formation token carried verbatim, never mapped"
|
||||
);
|
||||
// Every occupied f442 slot is a starter (indices 0..=10).
|
||||
assert!(sq.slots.iter().all(|s| !s.is_on_bench));
|
||||
// The captain flag lands on exactly the captain's slot (id 100000001 @ index 8).
|
||||
let caps: Vec<_> = sq.slots.iter().filter(|s| s.is_captain).collect();
|
||||
assert_eq!(caps.len(), 1);
|
||||
assert_eq!(caps[0].owned_card_id, "oc-100000001");
|
||||
assert_eq!(caps[0].index, 8);
|
||||
assert_eq!(caps[0].kit_number, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unresolved_occupied_id_is_reported_never_dropped_silently() {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
// Resolver missing one occupied id (100000025).
|
||||
let mut ids = full_resolver().0;
|
||||
ids.remove(&100000025);
|
||||
let sq = to_proposed(&put, &MapResolver(ids));
|
||||
assert_eq!(
|
||||
sq.slots.len(),
|
||||
10,
|
||||
"the unresolved slot is not emitted as a player"
|
||||
);
|
||||
assert_eq!(
|
||||
sq.unresolved_wire_ids,
|
||||
vec![100000025],
|
||||
"reported for the caller to refuse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_slot_is_dropped_not_resolved() {
|
||||
// A minimal body: one occupied + one empty slot.
|
||||
let body = br#"{"id":0,"formation":"f442","captain":0,"players":[
|
||||
{"index":0,"itemData":{"id":100000003},"kitNumber":1},
|
||||
{"index":11,"itemData":{"id":0},"kitNumber":0}]}"#;
|
||||
let put = parse_squad_put(body).unwrap();
|
||||
let sq = to_proposed(&put, &full_resolver());
|
||||
assert_eq!(sq.slots.len(), 1);
|
||||
assert_eq!(sq.slots[0].index, 0);
|
||||
assert!(sq.unresolved_wire_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formation_token_round_trips_verbatim() {
|
||||
// Canonical formation is the FIFA token as-sent; f433 is preserved as
|
||||
// readily as f442 (the old lossy f442->"4-4-2" map is gone).
|
||||
for tok in ["f442", "f433"] {
|
||||
let body = format!(
|
||||
r#"{{"id":0,"formation":"{tok}","captain":0,"players":[{{"index":0,"itemData":{{"id":100000003}},"kitNumber":1}}]}}"#
|
||||
);
|
||||
let sq = to_proposed(&parse_squad_put(body.as_bytes()).unwrap(), &full_resolver());
|
||||
assert_eq!(sq.formation.as_deref(), Some(tok));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_ack_is_the_bare_id() {
|
||||
assert_eq!(save_ack(0), serde_json::json!({ "id": 0 }));
|
||||
assert_eq!(save_ack(7), serde_json::json!({ "id": 7 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_body_errors_never_empty_squad() {
|
||||
assert!(matches!(
|
||||
parse_squad_put(b"not json"),
|
||||
Err(SquadError::Parse(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
//! FIFA 17 **Squad Extension v1** — the adapter-owned, versioned payload that
|
||||
//! carries the squad's FIFA-only wire state that OpenFUT Core does not model.
|
||||
//!
|
||||
//! Core stores this serialized payload **opaquely** (never interpreting it) next
|
||||
//! to the canonical squad, anchored by a server fingerprint. This module owns its
|
||||
//! schema and meaning; Core must never import this type.
|
||||
//!
|
||||
//! ## What lives here (and why it is not canonical)
|
||||
//!
|
||||
//! | field | ownership rationale |
|
||||
//! |-------|---------------------|
|
||||
//! | `custom` | opaque 33-int string; meaning UNKNOWN, round-tripped verbatim |
|
||||
//! | `squad_type` | an observed FIFA wire token; no matching generic Core concept |
|
||||
//! | `kit_numbers` | keyed by **`owned_card_id`** — evidence: kit follows the player |
|
||||
//! | `manager` | a FIFA manager item ref; not a squad player, semantics opaque |
|
||||
//! | `kicktakers` | role→item refs; relationship to captain UNKNOWN, kept opaque |
|
||||
//! | `client_reported` | chemistry/rating/starRating — client shadow, NOT authority |
|
||||
//!
|
||||
//! ## Versioning
|
||||
//!
|
||||
//! Two independent version numbers must not be confused:
|
||||
//! * [`EXT_SCHEMA_VERSION`] — the version of **this** payload schema. Stored in
|
||||
//! Core's `game_entity_ext.schema_version` and re-checked on read
|
||||
//! ([`Fifa17SquadExtensionV1::from_payload`] rejects any other version).
|
||||
//! * Core's own DB storage schema version — a Core concern, unrelated to this.
|
||||
//!
|
||||
//! [`EXT_NAMESPACE`] is the opaque scope key Core files the row under.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::fut::squad::{ClientReportedSquadEval, Fifa17SquadPut, ProposedSquad, SquadEntityRef};
|
||||
|
||||
/// Opaque scope key Core files this extension under (`game_entity_ext.namespace`).
|
||||
pub const EXT_NAMESPACE: &str = "fifa17.squad";
|
||||
|
||||
/// The version of THIS payload schema (goes into `OpaqueExtensionWrite.schema_version`).
|
||||
/// Distinct from Core's DB storage schema version.
|
||||
pub const EXT_SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
/// A FIFA item reference `{ id, dream }` preserved verbatim from the wire. `id`
|
||||
/// is the FIFA wire item id (opaque to this extension — used for manager and
|
||||
/// kicktaker refs whose semantics are not modelled).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WireItemRef {
|
||||
pub id: i64,
|
||||
#[serde(default)]
|
||||
pub dream: bool,
|
||||
}
|
||||
|
||||
impl From<&SquadEntityRef> for WireItemRef {
|
||||
fn from(r: &SquadEntityRef) -> Self {
|
||||
WireItemRef {
|
||||
id: r.id,
|
||||
dream: r.dream,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A kicktaker slot preserved verbatim. `index` is the role slot (0..=4 observed);
|
||||
/// `item` is the referenced FIFA wire item. The role→player meaning and any
|
||||
/// relationship to the captain are UNKNOWN, so this is stored opaquely and never
|
||||
/// normalized to the captain or to a Core `owned_card_id`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct KicktakerRef {
|
||||
pub index: i64,
|
||||
#[serde(flatten)]
|
||||
pub item: WireItemRef,
|
||||
}
|
||||
|
||||
/// FIFA 17 Squad Extension, version 1. Serialized to the opaque payload Core stores.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Fifa17SquadExtensionV1 {
|
||||
/// Opaque 33-int array as a JSON-encoded string, verbatim. Never decoded.
|
||||
#[serde(default)]
|
||||
pub custom: Option<String>,
|
||||
/// FIFA squad-type wire token (e.g. `"REGULAR_SQUAD"`).
|
||||
#[serde(default)]
|
||||
pub squad_type: Option<String>,
|
||||
/// kit number per player, keyed by Core `owned_card_id`. Keyed by the player
|
||||
/// instance — NEVER by slot/index or by card definition — because the wire
|
||||
/// proves the kit number follows the player across swaps and formation change.
|
||||
#[serde(default)]
|
||||
pub kit_numbers: BTreeMap<String, i64>,
|
||||
/// Manager item ref(s), opaque. Not a squad player; not shaped as an item.
|
||||
#[serde(default)]
|
||||
pub manager: Vec<WireItemRef>,
|
||||
/// Kicktaker role refs, opaque (see [`KicktakerRef`]).
|
||||
#[serde(default)]
|
||||
pub kicktakers: Vec<KicktakerRef>,
|
||||
/// Client-reported evaluation (shadow state). NEVER Core's authoritative
|
||||
/// evaluation and never reconciled with it.
|
||||
#[serde(default)]
|
||||
pub client_reported: ClientReportedSquadEval,
|
||||
}
|
||||
|
||||
/// Errors reading a stored extension payload.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum ExtError {
|
||||
/// The stored `schema_version` is not one this adapter understands. Never
|
||||
/// silently coerced — the caller decides (e.g. treat as unreadable).
|
||||
UnsupportedSchemaVersion(i64),
|
||||
/// The payload bytes did not deserialize as this schema.
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExtError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ExtError::UnsupportedSchemaVersion(v) => {
|
||||
write!(f, "unsupported fifa17 squad extension schema version {v} (want {EXT_SCHEMA_VERSION})")
|
||||
}
|
||||
ExtError::Parse(e) => write!(f, "fifa17 squad extension parse error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for ExtError {}
|
||||
|
||||
impl Fifa17SquadExtensionV1 {
|
||||
/// Build the extension from the parsed PUT plus the resolved canonical squad.
|
||||
/// `custom`/`squad_type`/manager/kicktakers/client eval come straight off the
|
||||
/// wire; kit numbers are re-keyed from slot index onto the resolved
|
||||
/// `owned_card_id` so they stay bound to the player, not the slot.
|
||||
pub fn from_put(put: &Fifa17SquadPut, canonical: &ProposedSquad) -> Self {
|
||||
let kit_numbers = canonical
|
||||
.slots
|
||||
.iter()
|
||||
.map(|s| (s.owned_card_id.clone(), s.kit_number))
|
||||
.collect();
|
||||
Fifa17SquadExtensionV1 {
|
||||
custom: put.custom.clone(),
|
||||
squad_type: put.squad_type.clone(),
|
||||
kit_numbers,
|
||||
manager: put.manager.iter().map(WireItemRef::from).collect(),
|
||||
kicktakers: put
|
||||
.kicktakers
|
||||
.iter()
|
||||
.map(|k| KicktakerRef {
|
||||
index: k.index,
|
||||
item: WireItemRef {
|
||||
id: k.id,
|
||||
dream: k.dream,
|
||||
},
|
||||
})
|
||||
.collect(),
|
||||
client_reported: ClientReportedSquadEval {
|
||||
chemistry: put.chemistry,
|
||||
rating: put.rating,
|
||||
star_rating: put.star_rating,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize to the opaque payload string Core stores.
|
||||
pub fn to_payload(&self) -> String {
|
||||
// Infallible for this type (no maps with non-string keys, no floats).
|
||||
serde_json::to_string(self).expect("fifa17 squad extension serializes")
|
||||
}
|
||||
|
||||
/// Parse a stored payload, enforcing the schema version FIRST. A version this
|
||||
/// adapter does not understand is rejected — never coerced or ignored.
|
||||
pub fn from_payload(schema_version: i64, payload: &str) -> Result<Self, ExtError> {
|
||||
if schema_version != EXT_SCHEMA_VERSION {
|
||||
return Err(ExtError::UnsupportedSchemaVersion(schema_version));
|
||||
}
|
||||
serde_json::from_str(payload).map_err(|e| ExtError::Parse(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors building a squad write from a PUT. A save is refused, never silently
|
||||
/// degraded, when it cannot be expressed faithfully as a canonical replacement.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum SquadBuildError {
|
||||
/// One or more occupied wire ids did not reverse-map to a Core owned item.
|
||||
/// Saving would silently drop an owned player — refused.
|
||||
UnresolvedWireIds(Vec<i64>),
|
||||
/// The same Core owned item appears in two slots. A full replacement cannot
|
||||
/// place one instance twice (two *copies* of a definition are distinct owned
|
||||
/// items and are fine — this is the same `owned_card_id` twice).
|
||||
DuplicateOwnedItem(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SquadBuildError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SquadBuildError::UnresolvedWireIds(ids) => {
|
||||
write!(f, "unresolved FIFA wire item ids (save refused): {ids:?}")
|
||||
}
|
||||
SquadBuildError::DuplicateOwnedItem(id) => {
|
||||
write!(f, "owned item {id} placed in two slots (save refused)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for SquadBuildError {}
|
||||
|
||||
/// The adapter's PUT-build output: a canonical replacement plus the FIFA-only
|
||||
/// extension. The host maps `canonical` onto Core's `SquadReplacement` and
|
||||
/// serializes `extension` into an `OpaqueExtensionWrite`
|
||||
/// (`namespace = EXT_NAMESPACE`, `schema_version = EXT_SCHEMA_VERSION`,
|
||||
/// `payload = extension.to_payload()`) so both commit in one Core transaction.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SquadWriteBuild {
|
||||
pub canonical: ProposedSquad,
|
||||
pub extension: Fifa17SquadExtensionV1,
|
||||
}
|
||||
|
||||
/// Build a full-replacement squad write from a parsed PUT and a host-supplied
|
||||
/// wire→owned resolver. Refuses (never degrades) on any unresolved occupied id
|
||||
/// or a duplicate owned item.
|
||||
///
|
||||
/// The resolver only maps identity; it is NOT authorization. The host still
|
||||
/// verifies every resolved `owned_card_id` belongs to the active FIFA 17
|
||||
/// profile/club before committing — a resolvable id is not proof of ownership.
|
||||
pub fn build_squad_write(
|
||||
put: &Fifa17SquadPut,
|
||||
resolver: &dyn crate::fut::squad::SquadWireResolver,
|
||||
) -> Result<SquadWriteBuild, SquadBuildError> {
|
||||
let canonical = crate::fut::squad::to_proposed(put, resolver);
|
||||
if !canonical.unresolved_wire_ids.is_empty() {
|
||||
return Err(SquadBuildError::UnresolvedWireIds(
|
||||
canonical.unresolved_wire_ids.clone(),
|
||||
));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for slot in &canonical.slots {
|
||||
if !seen.insert(slot.owned_card_id.as_str()) {
|
||||
return Err(SquadBuildError::DuplicateOwnedItem(
|
||||
slot.owned_card_id.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let extension = Fifa17SquadExtensionV1::from_put(put, &canonical);
|
||||
Ok(SquadWriteBuild {
|
||||
canonical,
|
||||
extension,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fut::squad::{parse_squad_put, SquadWireResolver};
|
||||
use std::collections::HashMap;
|
||||
|
||||
const PUT_F442: &str = include_str!("../../fixtures/utas/squad_put_f442.json");
|
||||
|
||||
struct MapResolver(HashMap<i64, String>);
|
||||
impl SquadWireResolver for MapResolver {
|
||||
fn owned_id_for_wire(&self, wire: i64) -> Option<String> {
|
||||
self.0.get(&wire).cloned()
|
||||
}
|
||||
}
|
||||
fn full_resolver() -> MapResolver {
|
||||
let ids = [
|
||||
100000003, 100000010, 100000005, 100000008, 100000007, 100000006, 100000004, 100000009,
|
||||
100000001, 100000002, 100000025,
|
||||
];
|
||||
MapResolver(ids.iter().map(|&w| (w, format!("oc-{w}"))).collect())
|
||||
}
|
||||
|
||||
fn built() -> SquadWriteBuild {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
build_squad_write(&put, &full_resolver()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serde_round_trips_the_whole_extension() {
|
||||
let ext = built().extension;
|
||||
let payload = ext.to_payload();
|
||||
let back = Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, &payload).unwrap();
|
||||
assert_eq!(ext, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_is_preserved_byte_for_byte() {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
let ext = built().extension;
|
||||
assert_eq!(ext.custom, put.custom, "opaque custom carried verbatim");
|
||||
// survives a serialize/parse cycle unchanged.
|
||||
let back =
|
||||
Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, &ext.to_payload()).unwrap();
|
||||
assert_eq!(back.custom, put.custom);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kit_number_is_keyed_by_owned_item_not_slot() {
|
||||
let ext = built().extension;
|
||||
// In the f442 fixture, owned oc-100000001 (captain) wears kit 8 at index 8;
|
||||
// oc-100000025 wears kit 11 at index 10. Keyed by owned id, not index.
|
||||
assert_eq!(ext.kit_numbers.get("oc-100000001"), Some(&8));
|
||||
assert_eq!(ext.kit_numbers.get("oc-100000025"), Some(&11));
|
||||
assert_eq!(ext.kit_numbers.len(), 11, "one per occupied slot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_reported_eval_is_carried_as_shadow() {
|
||||
let ext = built().extension;
|
||||
assert_eq!(
|
||||
ext.client_reported,
|
||||
ClientReportedSquadEval {
|
||||
chemistry: Some(52),
|
||||
rating: Some(90),
|
||||
star_rating: Some(90)
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn manager_and_kicktakers_preserved_opaquely() {
|
||||
let ext = built().extension;
|
||||
assert_eq!(
|
||||
ext.manager,
|
||||
vec![WireItemRef {
|
||||
id: 100000427,
|
||||
dream: false
|
||||
}]
|
||||
);
|
||||
assert_eq!(ext.kicktakers.len(), 5);
|
||||
// All five reference the same wire id in this capture; carried verbatim,
|
||||
// NEVER normalized to the captain even though they coincide here.
|
||||
assert!(ext.kicktakers.iter().all(|k| k.item.id == 100000001));
|
||||
assert_eq!(ext.kicktakers[0].index, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_schema_version_is_rejected_not_coerced() {
|
||||
let payload = built().extension.to_payload();
|
||||
assert_eq!(
|
||||
Fifa17SquadExtensionV1::from_payload(2, &payload),
|
||||
Err(ExtError::UnsupportedSchemaVersion(2))
|
||||
);
|
||||
assert_eq!(
|
||||
Fifa17SquadExtensionV1::from_payload(0, &payload),
|
||||
Err(ExtError::UnsupportedSchemaVersion(0))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_payload_errors() {
|
||||
assert!(matches!(
|
||||
Fifa17SquadExtensionV1::from_payload(EXT_SCHEMA_VERSION, "not json"),
|
||||
Err(ExtError::Parse(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_refuses_unresolved_wire_ids() {
|
||||
let put = parse_squad_put(PUT_F442.as_bytes()).unwrap();
|
||||
let mut ids = full_resolver().0;
|
||||
ids.remove(&100000025);
|
||||
let err = build_squad_write(&put, &MapResolver(ids)).unwrap_err();
|
||||
assert_eq!(err, SquadBuildError::UnresolvedWireIds(vec![100000025]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_refuses_duplicate_owned_item() {
|
||||
// Two occupied slots resolving to the SAME owned id (one instance twice).
|
||||
let body = br#"{"id":0,"formation":"f442","captain":100000003,"players":[
|
||||
{"index":0,"itemData":{"id":100000003},"kitNumber":1},
|
||||
{"index":1,"itemData":{"id":100000004},"kitNumber":2}]}"#;
|
||||
let put = parse_squad_put(body).unwrap();
|
||||
let mut m = HashMap::new();
|
||||
m.insert(100000003, "oc-dup".to_string());
|
||||
m.insert(100000004, "oc-dup".to_string()); // collide onto same owned id
|
||||
let err = build_squad_write(&put, &MapResolver(m)).unwrap_err();
|
||||
assert_eq!(
|
||||
err,
|
||||
SquadBuildError::DuplicateOwnedItem("oc-dup".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_replacement_carries_every_occupied_slot_no_diff() {
|
||||
let build = built();
|
||||
assert_eq!(build.canonical.slots.len(), 11, "whole squad, not a diff");
|
||||
assert_eq!(build.canonical.formation.as_deref(), Some("f442"));
|
||||
// No FIFA wire integer survives into the canonical slots.
|
||||
assert!(build
|
||||
.canonical
|
||||
.slots
|
||||
.iter()
|
||||
.all(|s| s.owned_card_id.starts_with("oc-")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
//! The **single** FIFA 17 squad projector: canonical Core squad + Fresh FIFA
|
||||
//! extension → the FIFA 17 squad wire object.
|
||||
//!
|
||||
//! One projector serves every squad read shape. `userMassInfo.squad` embeds the
|
||||
//! full object; `GET /squad/list` is a summary *subset* of it; a future
|
||||
//! `/squad/active` is the same object again. Endpoint wrappers ([`user_mass_info_squad`],
|
||||
//! [`squad_list`]) only shape the outer envelope — there is deliberately no
|
||||
//! second squad domain model per endpoint.
|
||||
//!
|
||||
//! ## Purity / no N+1
|
||||
//!
|
||||
//! The projector touches no database, socket, or Core API. It consumes a
|
||||
//! [`SquadProjectionInput`] the host assembles from ONE bounded batch — Core's
|
||||
//! `read_squad_with_ext` (canonical squad + players + extension freshness) plus a
|
||||
//! single "all owned cards for this club" fetch joined against the in-memory card
|
||||
//! definitions. There is no per-slot lookup here or above.
|
||||
//!
|
||||
//! ## Item identity is shared, never reconstructed
|
||||
//!
|
||||
//! Each occupied slot is shaped by the shared [`crate::fut::item::shape_item`],
|
||||
//! the same primitive `/club` uses — the projector never rebuilds the card shape
|
||||
//! itself, so squad items and `/club` items cannot drift, and two owned copies of
|
||||
//! one definition stay distinct (each carries its own resolved wire id).
|
||||
//!
|
||||
//! ## Fresh / Stale / Missing
|
||||
//!
|
||||
//! Freshness comes from Core and is surfaced, never buried in a default:
|
||||
//! * **Fresh** → project the full object.
|
||||
//! * **Stale** → NEVER overlay the stale extension on the newer canonical squad;
|
||||
//! return [`SquadProjection::Stale`] for the host to act on (e.g. fall back).
|
||||
//! * **Missing** → return [`SquadProjection::Missing`]; the projector does NOT
|
||||
//! fabricate a manager/custom/kicktakers/kit numbers just to emit a response.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::entities::ReverseEntityResolver;
|
||||
use crate::fut::item::{shape_item, CoreOwnedItem, ItemIdentityResolver};
|
||||
use crate::fut::squad::FIFA17_SQUAD_SLOTS;
|
||||
use crate::fut::squad_ext::Fifa17SquadExtensionV1;
|
||||
|
||||
/// Freshness of the FIFA 17 extension relative to the current canonical squad,
|
||||
/// as reported by Core's `read_squad_with_ext`. This is a game-independent mirror
|
||||
/// the host populates from Core's `SquadExtState`; the adapter never computes the
|
||||
/// canonical fingerprint itself (that is Core's server-side job).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SquadExtInput {
|
||||
Fresh(Fifa17SquadExtensionV1),
|
||||
/// The stored extension whose fingerprint no longer matches the canonical
|
||||
/// squad. Carried so the host can log/inspect it, but the projector NEVER
|
||||
/// applies it over the newer canonical squad (mirrors Core's
|
||||
/// `SquadExtState::Stale { stored, .. }`).
|
||||
Stale(Fifa17SquadExtensionV1),
|
||||
Missing,
|
||||
}
|
||||
|
||||
/// One canonical slot as read back from Core. `is_on_bench` is carried through
|
||||
/// from Core, never re-derived from the formation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProjectionSlot {
|
||||
pub owned_card_id: String,
|
||||
pub index: i64,
|
||||
pub is_captain: bool,
|
||||
pub is_on_bench: bool,
|
||||
}
|
||||
|
||||
/// Everything the pure projector needs to render one full squad.
|
||||
pub struct SquadProjectionInput<'a> {
|
||||
/// FIFA wire squad id (`0` = active).
|
||||
pub fifa_squad_id: i64,
|
||||
pub name: String,
|
||||
/// FIFA formation token, verbatim from the canonical squad (never mapped).
|
||||
pub formation: String,
|
||||
pub slots: Vec<ProjectionSlot>,
|
||||
pub ext: SquadExtInput,
|
||||
/// Every owned item a slot references, keyed by `owned_card_id`. Assembled by
|
||||
/// the host in one batch — the projector only reads from it.
|
||||
pub owned: &'a HashMap<String, CoreOwnedItem>,
|
||||
}
|
||||
|
||||
/// Result of a projection, with the extension-freshness verdict surfaced.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SquadProjection {
|
||||
/// A fully projected FIFA 17 squad object (the `userMassInfo.squad` shape,
|
||||
/// minus session envelope fields the endpoint wrapper adds).
|
||||
Projected(Value),
|
||||
/// The stored extension is stale vs the canonical squad — not applied.
|
||||
Stale,
|
||||
/// No extension stored — nothing fabricated.
|
||||
Missing,
|
||||
}
|
||||
|
||||
/// Hard projection failures — a squad cannot be rendered faithfully. Never
|
||||
/// silently degraded (a squad cannot drop a starter the way `/club` drops a card).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SquadProjectError {
|
||||
/// A slot references an `owned_card_id` absent from the projection input.
|
||||
MissingOwnedItem(String),
|
||||
/// An occupied slot's owned item has no real FIFA asset identity — it cannot
|
||||
/// be rendered and MUST NOT be faked.
|
||||
NoFifaIdentity(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SquadProjectError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
SquadProjectError::MissingOwnedItem(id) => {
|
||||
write!(f, "projection input missing owned item {id}")
|
||||
}
|
||||
SquadProjectError::NoFifaIdentity(id) => {
|
||||
write!(
|
||||
f,
|
||||
"owned item {id} has no real FIFA asset identity (cannot render)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
impl std::error::Error for SquadProjectError {}
|
||||
|
||||
/// Project a squad. On `Fresh`, returns the full FIFA squad object; on
|
||||
/// `Stale`/`Missing`, returns that verdict without fabricating anything.
|
||||
pub fn project_squad<I: ItemIdentityResolver + ?Sized>(
|
||||
input: &SquadProjectionInput<'_>,
|
||||
ident: &I,
|
||||
ent: &impl ReverseEntityResolver,
|
||||
) -> Result<SquadProjection, SquadProjectError> {
|
||||
let ext = match &input.ext {
|
||||
SquadExtInput::Stale(_) => return Ok(SquadProjection::Stale),
|
||||
SquadExtInput::Missing => return Ok(SquadProjection::Missing),
|
||||
SquadExtInput::Fresh(ext) => ext,
|
||||
};
|
||||
|
||||
// Index occupied slots by their FIFA array index for O(1) fill.
|
||||
let by_index: HashMap<i64, &ProjectionSlot> =
|
||||
input.slots.iter().map(|s| (s.index, s)).collect();
|
||||
|
||||
let mut players = Vec::with_capacity(FIFA17_SQUAD_SLOTS as usize);
|
||||
let mut captain_wire: i64 = 0;
|
||||
|
||||
for index in 0..FIFA17_SQUAD_SLOTS {
|
||||
match by_index.get(&index) {
|
||||
Some(slot) => {
|
||||
let item = input.owned.get(&slot.owned_card_id).ok_or_else(|| {
|
||||
SquadProjectError::MissingOwnedItem(slot.owned_card_id.clone())
|
||||
})?;
|
||||
let id = ident
|
||||
.resolve(item)
|
||||
.ok_or_else(|| SquadProjectError::NoFifaIdentity(slot.owned_card_id.clone()))?;
|
||||
if slot.is_captain {
|
||||
captain_wire = id.item_id as i64;
|
||||
}
|
||||
// kit follows the player: look it up by owned id, never by index.
|
||||
let kit = ext
|
||||
.kit_numbers
|
||||
.get(&slot.owned_card_id)
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
players.push(json!({
|
||||
"index": index,
|
||||
"itemData": shape_item(item, id, ent),
|
||||
"kitNumber": kit,
|
||||
}));
|
||||
}
|
||||
None => players.push(json!({
|
||||
"index": index,
|
||||
"itemData": { "id": 0, "dream": false },
|
||||
"kitNumber": 0,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
let squad = json!({
|
||||
"id": input.fifa_squad_id,
|
||||
"squadName": input.name,
|
||||
"formation": input.formation,
|
||||
"squadType": ext.squad_type,
|
||||
"chemistry": ext.client_reported.chemistry,
|
||||
"starRating": ext.client_reported.star_rating,
|
||||
"rating": ext.client_reported.rating,
|
||||
"captain": captain_wire,
|
||||
"manager": ext.manager,
|
||||
"custom": ext.custom,
|
||||
"players": players,
|
||||
"kicktakers": ext.kicktakers,
|
||||
});
|
||||
Ok(SquadProjection::Projected(squad))
|
||||
}
|
||||
|
||||
/// Wrap a projected squad object into the `userMassInfo.squad` shape, injecting
|
||||
/// the session-envelope fields the projector does not own (`personaId`, plus the
|
||||
/// observed constants `changed: 0`, `actives: []`).
|
||||
pub fn user_mass_info_squad(projected: Value, persona_id: i64) -> Value {
|
||||
let mut obj = projected;
|
||||
if let Value::Object(map) = &mut obj {
|
||||
map.insert("personaId".into(), json!(persona_id));
|
||||
map.insert("changed".into(), json!(0));
|
||||
map.insert("actives".into(), json!([]));
|
||||
}
|
||||
obj
|
||||
}
|
||||
|
||||
/// The `GET /squad/list` summary response — a subset of the SAME projected
|
||||
/// object, wrapped in `{"squad":[ … ]}`. Not a separate domain projection.
|
||||
pub fn squad_list(projected: &Value) -> Value {
|
||||
let summary = json!({
|
||||
"id": projected.get("id").cloned().unwrap_or(Value::Null),
|
||||
"squadName": projected.get("squadName").cloned().unwrap_or(Value::Null),
|
||||
"formation": projected.get("formation").cloned().unwrap_or(Value::Null),
|
||||
"squadType": projected.get("squadType").cloned().unwrap_or(Value::Null),
|
||||
"rating": projected.get("rating").cloned().unwrap_or(Value::Null),
|
||||
"chemistry": projected.get("chemistry").cloned().unwrap_or(Value::Null),
|
||||
});
|
||||
json!({ "squad": [summary] })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::fut::entities::Fifa17Entities;
|
||||
use crate::fut::item::Fifa17Identity;
|
||||
|
||||
// A resolver that mints a distinct wire id per owned item and a fixed asset.
|
||||
struct TableIdentity(HashMap<String, Fifa17Identity>);
|
||||
impl ItemIdentityResolver for TableIdentity {
|
||||
fn resolve(&self, it: &CoreOwnedItem) -> Option<Fifa17Identity> {
|
||||
self.0.get(&it.owned_card_id).copied()
|
||||
}
|
||||
}
|
||||
|
||||
fn ent() -> Fifa17Entities {
|
||||
Fifa17Entities::from_maps(HashMap::new(), HashMap::new(), HashMap::new())
|
||||
}
|
||||
|
||||
fn owned_item(id: &str, card: &str) -> CoreOwnedItem {
|
||||
CoreOwnedItem {
|
||||
owned_card_id: id.into(),
|
||||
card_id: card.into(),
|
||||
rating: 84,
|
||||
position: "ST".into(),
|
||||
nation: "n".into(),
|
||||
league: "l".into(),
|
||||
club: "c".into(),
|
||||
attributes: [80, 80, 80, 80, 40, 80],
|
||||
}
|
||||
}
|
||||
|
||||
fn one_slot_input<'a>(
|
||||
owned: &'a HashMap<String, CoreOwnedItem>,
|
||||
ext: SquadExtInput,
|
||||
) -> SquadProjectionInput<'a> {
|
||||
SquadProjectionInput {
|
||||
fifa_squad_id: 0,
|
||||
name: "OpenFUT".into(),
|
||||
formation: "f442".into(),
|
||||
slots: vec![ProjectionSlot {
|
||||
owned_card_id: "oc1".into(),
|
||||
index: 0,
|
||||
is_captain: true,
|
||||
is_on_bench: false,
|
||||
}],
|
||||
ext,
|
||||
owned,
|
||||
}
|
||||
}
|
||||
|
||||
fn fresh_ext() -> Fifa17SquadExtensionV1 {
|
||||
let mut kit = std::collections::BTreeMap::new();
|
||||
kit.insert("oc1".to_string(), 9);
|
||||
Fifa17SquadExtensionV1 {
|
||||
custom: Some("[1,2,3]".into()),
|
||||
squad_type: Some("REGULAR_SQUAD".into()),
|
||||
kit_numbers: kit,
|
||||
manager: vec![],
|
||||
kicktakers: vec![],
|
||||
client_reported: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fresh_projects_full_23_slot_array_with_captain_wire_id() {
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
|
||||
let ident = TableIdentity(HashMap::from([(
|
||||
"oc1".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000042,
|
||||
asset_id: 20801,
|
||||
resource_id: 20801,
|
||||
rareflag: 1,
|
||||
},
|
||||
)]));
|
||||
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!("expected Projected");
|
||||
};
|
||||
assert_eq!(
|
||||
v["players"].as_array().unwrap().len(),
|
||||
23,
|
||||
"fixed 23-slot array"
|
||||
);
|
||||
assert_eq!(
|
||||
v["players"][0]["itemData"]["id"], 100000042,
|
||||
"wire id, not resourceId"
|
||||
);
|
||||
assert_eq!(v["players"][0]["itemData"]["resourceId"], 20801);
|
||||
assert_eq!(v["players"][0]["kitNumber"], 9, "kit from ext by owned id");
|
||||
assert_eq!(v["players"][1]["itemData"]["id"], 0, "empty slot");
|
||||
assert_eq!(v["captain"], 100000042, "captain is the resolved WIRE id");
|
||||
assert_ne!(v["captain"], 20801, "captain must NOT be the resourceId");
|
||||
assert_eq!(v["custom"], "[1,2,3]");
|
||||
assert_eq!(v["formation"], "f442");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_is_never_applied() {
|
||||
let owned = HashMap::new();
|
||||
// A stale extension IS carried (host may log it) but must not be applied.
|
||||
let input = one_slot_input(&owned, SquadExtInput::Stale(fresh_ext()));
|
||||
let ident = TableIdentity(HashMap::new());
|
||||
assert_eq!(
|
||||
project_squad(&input, &ident, &ent()).unwrap(),
|
||||
SquadProjection::Stale
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_is_explicit_never_fabricated() {
|
||||
let owned = HashMap::new();
|
||||
let input = one_slot_input(&owned, SquadExtInput::Missing);
|
||||
let ident = TableIdentity(HashMap::new());
|
||||
assert_eq!(
|
||||
project_squad(&input, &ident, &ent()).unwrap(),
|
||||
SquadProjection::Missing
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occupied_starter_without_asset_identity_is_refused_not_faked() {
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc1".to_string(), owned_item("oc1", "card_x"));
|
||||
let ident = TableIdentity(HashMap::new()); // resolves nothing
|
||||
let input = one_slot_input(&owned, SquadExtInput::Fresh(fresh_ext()));
|
||||
assert_eq!(
|
||||
project_squad(&input, &ident, &ent()),
|
||||
Err(SquadProjectError::NoFifaIdentity("oc1".into()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_owned_copies_of_one_definition_project_as_distinct_players() {
|
||||
// Same card definition -> same resourceId; two distinct owned instances
|
||||
// in two slots with distinct kits must stay distinct on the wire.
|
||||
let mut owned = HashMap::new();
|
||||
owned.insert("oc-a".to_string(), owned_item("oc-a", "fifa17_101490"));
|
||||
owned.insert("oc-b".to_string(), owned_item("oc-b", "fifa17_101490"));
|
||||
let ident = TableIdentity(HashMap::from([
|
||||
(
|
||||
"oc-a".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000030,
|
||||
asset_id: 101490,
|
||||
resource_id: 101490,
|
||||
rareflag: 1,
|
||||
},
|
||||
),
|
||||
(
|
||||
"oc-b".to_string(),
|
||||
Fifa17Identity {
|
||||
item_id: 100000031,
|
||||
asset_id: 101490,
|
||||
resource_id: 101490,
|
||||
rareflag: 1,
|
||||
},
|
||||
),
|
||||
]));
|
||||
let mut kit = std::collections::BTreeMap::new();
|
||||
kit.insert("oc-a".to_string(), 7);
|
||||
kit.insert("oc-b".to_string(), 19);
|
||||
let ext = Fifa17SquadExtensionV1 {
|
||||
kit_numbers: kit,
|
||||
..fresh_ext()
|
||||
};
|
||||
let owned_ref = &owned;
|
||||
let input = SquadProjectionInput {
|
||||
fifa_squad_id: 0,
|
||||
name: "OpenFUT".into(),
|
||||
formation: "f442".into(),
|
||||
slots: vec![
|
||||
ProjectionSlot {
|
||||
owned_card_id: "oc-a".into(),
|
||||
index: 0,
|
||||
is_captain: false,
|
||||
is_on_bench: false,
|
||||
},
|
||||
ProjectionSlot {
|
||||
owned_card_id: "oc-b".into(),
|
||||
index: 1,
|
||||
is_captain: false,
|
||||
is_on_bench: false,
|
||||
},
|
||||
],
|
||||
ext: SquadExtInput::Fresh(ext),
|
||||
owned: owned_ref,
|
||||
};
|
||||
let SquadProjection::Projected(v) = project_squad(&input, &ident, &ent()).unwrap() else {
|
||||
panic!();
|
||||
};
|
||||
let a = &v["players"][0]["itemData"];
|
||||
let b = &v["players"][1]["itemData"];
|
||||
assert_eq!(
|
||||
a["resourceId"], b["resourceId"],
|
||||
"same definition => same asset"
|
||||
);
|
||||
assert_ne!(
|
||||
a["id"], b["id"],
|
||||
"distinct owned copies keep distinct wire ids"
|
||||
);
|
||||
assert_eq!(v["players"][0]["kitNumber"], 7);
|
||||
assert_eq!(
|
||||
v["players"][1]["kitNumber"], 19,
|
||||
"kit stays with the instance"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
//! FIFA 17 Store pack catalogue + `/store/purchasegroup` wire shaping.
|
||||
//!
|
||||
//! A faithful Rust port of the Python oracle's `PACK_CATALOG` + `_pack_body` +
|
||||
//! `store_catalog` assembly (`fifa17-recon/tools/{fut_store,utas_server}.py`) at the
|
||||
//! **production flag defaults** (`FUT_STORE_DISPLAYGROUP=1` on, `FUT_STORE_GROUPID=0`
|
||||
//! off, `FUT_PRICE_PROBE=0` off). Parity is pinned by differential fixtures generated
|
||||
//! from the Python oracle (`tests/fixtures/purchasegroup_*.json`).
|
||||
//!
|
||||
//! ## Scope / split-brain safety
|
||||
//!
|
||||
//! This is **pure wire shaping** — no economy state, no IO. [`build_purchasegroup`]
|
||||
//! is a function of `(owned unopened pack ids, empty-My-Packs StoreMode)`. It is
|
||||
//! deliberately **not yet wired** into the live host: serving purchasegroup from Rust
|
||||
//! requires an authoritative Rust owner of `unopenedPackIds`, and today Python is the
|
||||
//! single writer of coins + unopened packs (BUY, quick-sell, rewards). Wiring this
|
||||
//! before that economy authority exists would create a dual-write/split-brain. See
|
||||
//! the R3 economy-authority prerequisite in the vault (`Rust UTAS Migration`).
|
||||
//!
|
||||
//! ## Economy-parameter provenance
|
||||
//!
|
||||
//! Prices, counts and odds are the current OpenFUT **PLACEHOLDER** economy, NOT
|
||||
//! EA-authentic (the overnight audit established the store economy is invented). The
|
||||
//! wire *shape* is EA-observed/oracle-verified; the *numbers* are placeholders.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::fut::store_session::{StoreMode, SENTINEL_PACK_ID};
|
||||
|
||||
/// A FIFA 17 Store pack definition. Wire shape is oracle-verified; the economy
|
||||
/// numbers (`price`/`count`/`special_chance`) are OpenFUT PLACEHOLDER, not EA-authentic.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PackDef {
|
||||
pub id: u64,
|
||||
pub name: &'static str,
|
||||
pub price: u64,
|
||||
pub count: u64,
|
||||
pub gold: bool,
|
||||
pub special_chance: f64,
|
||||
/// Reward-only pack (no purchase path): excluded from the normal catalogue,
|
||||
/// rendered only when owned (in `unopenedPackIds`).
|
||||
pub owned_only: bool,
|
||||
}
|
||||
|
||||
/// The current supported FIFA 17 pack catalogue (`fut_store.py:820`). Only observed/
|
||||
/// currently-supported ids. The 65534 sentinel is deliberately ABSENT — it is a
|
||||
/// compatibility shim, never a catalogue pack (never purchasable/openable).
|
||||
pub const PACK_CATALOG: &[PackDef] = &[
|
||||
PackDef {
|
||||
id: 1,
|
||||
name: "Bronze Pack",
|
||||
price: 400,
|
||||
count: 5,
|
||||
gold: false,
|
||||
special_chance: 0.005,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 5,
|
||||
name: "Gold Pack",
|
||||
price: 5000,
|
||||
count: 7,
|
||||
gold: true,
|
||||
special_chance: 0.03,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 6,
|
||||
name: "Premium Gold",
|
||||
price: 15000,
|
||||
count: 11,
|
||||
gold: true,
|
||||
special_chance: 0.08,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 7,
|
||||
name: "Special Players Pack",
|
||||
price: 25000,
|
||||
count: 11,
|
||||
gold: true,
|
||||
special_chance: 1.0,
|
||||
owned_only: false,
|
||||
},
|
||||
PackDef {
|
||||
id: 70,
|
||||
name: "Reward Special Players Pack",
|
||||
price: 0,
|
||||
count: 11,
|
||||
gold: true,
|
||||
special_chance: 1.0,
|
||||
owned_only: true,
|
||||
},
|
||||
];
|
||||
|
||||
/// Look up a catalogue pack by id (the 65534 sentinel is never present).
|
||||
pub fn pack_by_id(id: u64) -> Option<&'static PackDef> {
|
||||
PACK_CATALOG.iter().find(|p| p.id == id)
|
||||
}
|
||||
|
||||
/// The FIFA17 StoreFront category token for a NORMAL pack tile (`utas_server.py:3579`):
|
||||
/// one of the six hard-coded tokens the client resolves.
|
||||
fn category(p: &PackDef) -> &'static str {
|
||||
if p.special_chance >= 1.0 {
|
||||
"special"
|
||||
} else if p.gold {
|
||||
"gold"
|
||||
} else {
|
||||
"bronze"
|
||||
}
|
||||
}
|
||||
|
||||
/// One `purchase[]` entry — the faithful `_pack_body` port (`utas_server.py:3474`) at
|
||||
/// production flag defaults. `owned` packs (My Packs / reward / sentinel) drop the
|
||||
/// purchase fields and take the `mypacks` display group.
|
||||
pub fn pack_body(p: &PackDef, idx: u64, owned: bool) -> Value {
|
||||
let mtx = std::cmp::max(1, p.price / 100);
|
||||
let mut body = json!({
|
||||
"assetId": p.id,
|
||||
"id": p.id,
|
||||
"packType": if p.gold { "GOLD" } else { "BRONZE" },
|
||||
"description": p.name,
|
||||
"state": "active",
|
||||
"saleType": "promo",
|
||||
"limitType": "NONE",
|
||||
"quantity": 0,
|
||||
"purchaseLimit": 0,
|
||||
"purchaseCount": 0,
|
||||
"isPremium": false,
|
||||
"sortPriority": idx,
|
||||
"currencies": [{ "name": "coins", "funds": p.price, "finalFunds": p.price }],
|
||||
"extPrice": {
|
||||
"finalPrice": { "amount": mtx, "currency": "mtx" },
|
||||
"originalPrice": { "amount": mtx, "currency": "mtx" },
|
||||
},
|
||||
"packContentInfo": {
|
||||
"bronzeQuantity": if p.gold { 0 } else { p.count },
|
||||
"silverQuantity": 0,
|
||||
"goldQuantity": if p.gold { p.count } else { 0 },
|
||||
"rareQuantity": if p.gold { p.count } else { 0 },
|
||||
"itemQuantity": p.count,
|
||||
},
|
||||
"unopened": owned,
|
||||
});
|
||||
let obj = body.as_object_mut().expect("pack body is a JSON object");
|
||||
if owned {
|
||||
// Reward/My-Packs tiles have no purchase path; leaving zero-value coin/mtx
|
||||
// objects makes the client render the price label as literal "undefined".
|
||||
obj.remove("currencies");
|
||||
obj.remove("extPrice");
|
||||
obj.insert(
|
||||
"displayGroup".into(),
|
||||
json!({ "value": "mypacks", "priority": idx }),
|
||||
);
|
||||
} else {
|
||||
obj.insert("displayGroup".into(), json!({ "value": category(p) }));
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// The synthetic empty-My-Packs sentinel `purchase[]` entry (id 65534): an owned-style
|
||||
/// body forced to `state:"active"`, `unopened:false`. Compatibility shim ONLY — 65534
|
||||
/// is absent from [`PACK_CATALOG`], so it can never be bought/opened/granted.
|
||||
pub fn sentinel_body(idx: u64) -> Value {
|
||||
let sentinel = PackDef {
|
||||
id: SENTINEL_PACK_ID,
|
||||
name: "",
|
||||
price: 0,
|
||||
count: 0,
|
||||
gold: true,
|
||||
special_chance: 0.0,
|
||||
owned_only: true,
|
||||
};
|
||||
let mut body = pack_body(&sentinel, idx, true);
|
||||
let obj = body
|
||||
.as_object_mut()
|
||||
.expect("sentinel body is a JSON object");
|
||||
obj.insert("state".into(), json!("active"));
|
||||
obj.insert("unopened".into(), json!(false));
|
||||
body
|
||||
}
|
||||
|
||||
/// Build the full `/store/purchasegroup` body from the authoritative unopened-pack ids
|
||||
/// and the frozen empty-My-Packs mode. Pure — mirrors `store_catalog` (`3627`):
|
||||
/// normal packs (1,5,6,7) first, then any owned packs, then the empty-My-Packs shim
|
||||
/// (sentinel for [`StoreMode::Sentinel`], nothing for [`StoreMode::CleanV1`]).
|
||||
pub fn build_purchasegroup(unopened_ids: &[u64], mode: StoreMode) -> Value {
|
||||
let mut packs: Vec<Value> = PACK_CATALOG
|
||||
.iter()
|
||||
.filter(|p| !p.owned_only)
|
||||
.enumerate()
|
||||
.map(|(i, p)| pack_body(p, i as u64 + 1, false))
|
||||
.collect();
|
||||
for (i, &pid) in unopened_ids.iter().enumerate() {
|
||||
if let Some(owned) = pack_by_id(pid) {
|
||||
packs.push(pack_body(owned, i as u64 + 1, true));
|
||||
}
|
||||
}
|
||||
if unopened_ids.is_empty() && mode == StoreMode::Sentinel {
|
||||
packs.push(sentinel_body(1));
|
||||
}
|
||||
json!({ "purchase": packs, "timestamp": 1596326400i64 })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Differential parity against the Python oracle. The fixtures under
|
||||
//! `tests/fixtures/purchasegroup_*.json` are generated by calling the oracle's
|
||||
//! `_pack_body`/`store_catalog` at production flag defaults; Rust must match
|
||||
//! them semantically (object key order is irrelevant to `serde_json::Value` eq).
|
||||
use super::*;
|
||||
|
||||
fn parse(s: &str) -> Value {
|
||||
serde_json::from_str(s).expect("fixture parses")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchasegroup_zero_sentinel_matches_oracle() {
|
||||
let got = build_purchasegroup(&[], StoreMode::Sentinel);
|
||||
let want = parse(include_str!(
|
||||
"../../tests/fixtures/purchasegroup_zero_sentinel.json"
|
||||
));
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchasegroup_zero_clean_matches_oracle() {
|
||||
let got = build_purchasegroup(&[], StoreMode::CleanV1);
|
||||
let want = parse(include_str!(
|
||||
"../../tests/fixtures/purchasegroup_zero_clean.json"
|
||||
));
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn purchasegroup_pack70_matches_oracle() {
|
||||
// Owned pack present -> no sentinel regardless of mode.
|
||||
let got = build_purchasegroup(&[70], StoreMode::Sentinel);
|
||||
let want = parse(include_str!(
|
||||
"../../tests/fixtures/purchasegroup_pack70.json"
|
||||
));
|
||||
assert_eq!(got, want);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentinel_absent_from_catalog() {
|
||||
assert!(pack_by_id(SENTINEL_PACK_ID).is_none());
|
||||
assert!(PACK_CATALOG.iter().all(|p| p.id != SENTINEL_PACK_ID));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_v1_empty_emits_no_mypacks_group() {
|
||||
let got = build_purchasegroup(&[], StoreMode::CleanV1);
|
||||
let ids: Vec<u64> = got["purchase"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|e| e["id"].as_u64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(ids, vec![1, 5, 6, 7]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn category_tokens_are_canonical() {
|
||||
assert_eq!(category(pack_by_id(1).unwrap()), "bronze");
|
||||
assert_eq!(category(pack_by_id(5).unwrap()), "gold");
|
||||
assert_eq!(category(pack_by_id(7).unwrap()), "special");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user