f9ca901a50
RE of the FUT front-end closed the question the Actions-panel investigation left open, and the answer retracts Q2 rather than completing it. `tradeState` reaches exactly ONE native branch in CardsDLL — `cmp …,0x4` at `0x18013e619`, "is it closed?" — and `inactive`(2) and `expired`(3) take the same edge, producing bit-identical `flagA`/`flagB` (exhaustive 22-site census of `[reg+0x88]` reads across the PE; confirmed live, both classes read glow=0 inbox=0). The value is then handed to the movie verbatim as the Flash property `STATE`, and the action gate lives in the APT/ActionScript FUT front-end: the trade-pile class partitions rows with `getCardsInAuction`/`isInActiveAuction` (traces `initPile() - IN AUCTION:` / `- NOT IN AUCTION:`) and only auction rows reach `PreCheckCardOptions` -> `handleTradeCardAction`. A non-auction row renders and can never be acted on, which is exactly what the operator saw. So the rows were never usable. "LIVE-CONFIRMED" established that they RENDER, which is not the same claim, and I treated it as if it were. The corpus said this before any of it was built — `plan-2026-08-06-transfer-market.md:731-733`: "`inactive` decodes but no client path treats it specially; do not emit it." The earlier note explaining that the warning "was written about the PRESENTATION function" was motivated reasoning. This also fires the corpus's own pre-registered falsifier E3 (:368-373). Removed: the `inactive` projection from `GET …/tradePile` and `…/trade/status`, `UnlistedCandidate`, `resolve_unlisted_pile`, `unlisted_record`, `Server::resolve_trade_pile`, and the two helpers that existed only to feed them (`MarketStore::blocking_core_items`, `Fifa17IdentityResolver::wire_for_owned_id`). Unlisted trade-pile membership is now internal state with no wire expression. Nothing is stranded: `/club` excludes only items with an ACTIVE listing, so an unlisted pile member stays visible in the club, which is where the client can act on it. Verified live after deploy — `/tradePile` total 7 -> 1 with zero `inactive` rows, `/trade/status` resolving only the real auction, coins unchanged at 29,843,976, and all six former rows present in `/club` (1965 items). Tests: 126 pass, fmt + clippy clean. Two guards replace the three tests that pinned the old behaviour: `the_trade_pile_advertises_only_real_auctions` and `trade_status_answers_only_about_real_auctions`. NOT fixed here, deliberately: `itemData.itemState: "listFS"` is not a FIFA 17 token (0 occurrences in CardsDLL md5 4de3493131d7d2ff7f8b360c5ac9b655, 0 in 4.26 GiB of process memory, decodes to -1; the real value is `forSale` = 5, and the Python oracle emits `listFS` too — which is why the differential never caught it). `CARD_OFFERSTATE` is one of three unresolved action-gate candidates and every actionable row observed carried -1, so that change ships alone with its own live A/B.
106 lines
3.6 KiB
Rust
106 lines
3.6 KiB
Rust
//! Durable FIFA 17 **client-data blob** store (`clientdata` / `userHubData`).
|
|
//!
|
|
//! FIFA persists opaque per-user client blobs via `PUT/POST …/clientdata/<key>`
|
|
//! and reads them back via `GET …/clientdata/<key>`. The blobs are entirely
|
|
//! client-defined (UI/hub state) — the server only round-trips them and never
|
|
//! interprets their contents. This store keeps them in memory keyed by
|
|
//! `<persona>:<key>` and persists the whole map to a JSON file on every write, so
|
|
//! the client's saved state survives a host restart.
|
|
//!
|
|
//! The blobs are non-authoritative client presentation state (NOT economy or
|
|
//! ownership), so a missing/unreadable backing file starts empty rather than
|
|
//! being a hard failure.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::PathBuf;
|
|
|
|
use parking_lot::Mutex;
|
|
use serde_json::Value;
|
|
|
|
/// In-memory client-data blobs, persisted to a JSON file on write.
|
|
pub struct ClientDataStore {
|
|
path: PathBuf,
|
|
map: Mutex<HashMap<String, Value>>,
|
|
}
|
|
|
|
impl ClientDataStore {
|
|
/// Open the store, loading any previously-persisted blobs. A missing or
|
|
/// unreadable file starts empty.
|
|
pub fn open(path: impl Into<PathBuf>) -> Self {
|
|
let path = path.into();
|
|
let map = std::fs::read(&path)
|
|
.ok()
|
|
.and_then(|b| serde_json::from_slice::<HashMap<String, Value>>(&b).ok())
|
|
.unwrap_or_default();
|
|
ClientDataStore {
|
|
path,
|
|
map: Mutex::new(map),
|
|
}
|
|
}
|
|
|
|
fn compound_key(persona: i64, key: &str) -> String {
|
|
format!("{persona}:{key}")
|
|
}
|
|
|
|
/// The stored blob for `<persona>:<key>`, or `None` if never written.
|
|
pub fn get(&self, persona: i64, key: &str) -> Option<Value> {
|
|
self.map
|
|
.lock()
|
|
.get(&Self::compound_key(persona, key))
|
|
.cloned()
|
|
}
|
|
|
|
/// Store `value` under `<persona>:<key>` and persist the whole map to disk.
|
|
/// The serialized snapshot is taken under the lock; the file write happens
|
|
/// after the lock is released.
|
|
pub fn put(&self, persona: i64, key: &str, value: Value) {
|
|
let snapshot = {
|
|
let mut map = self.map.lock();
|
|
map.insert(Self::compound_key(persona, key), value);
|
|
serde_json::to_vec(&*map).unwrap_or_default()
|
|
};
|
|
if let Some(parent) = self.path.parent() {
|
|
if !parent.as_os_str().is_empty() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
}
|
|
let _ = std::fs::write(&self.path, snapshot);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serde_json::json;
|
|
|
|
fn temp_path(tag: &str) -> PathBuf {
|
|
std::env::temp_dir().join(format!(
|
|
"openfut-clientdata-test-{}-{}.json",
|
|
std::process::id(),
|
|
tag
|
|
))
|
|
}
|
|
|
|
#[test]
|
|
fn round_trips_and_persists_across_reopen() {
|
|
let path = temp_path("roundtrip");
|
|
let _ = std::fs::remove_file(&path);
|
|
let store = ClientDataStore::open(&path);
|
|
assert_eq!(store.get(33_068_179, "userHubData"), None);
|
|
store.put(33_068_179, "userHubData", json!({"tiles": [1, 2, 3]}));
|
|
assert_eq!(
|
|
store.get(33_068_179, "userHubData"),
|
|
Some(json!({"tiles": [1, 2, 3]}))
|
|
);
|
|
// A different persona under the same key is isolated.
|
|
assert_eq!(store.get(1, "userHubData"), None);
|
|
// Reopening reads the persisted blob back.
|
|
let reopened = ClientDataStore::open(&path);
|
|
assert_eq!(
|
|
reopened.get(33_068_179, "userHubData"),
|
|
Some(json!({"tiles": [1, 2, 3]}))
|
|
);
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
}
|