host: ViewCards must return OWNED INSTANCES, not definition placeholders

GET ut/%s/item is FutViewCards and its ids are OWNED INSTANCE ids - the client
builds the query as ?idList=%lld (CardsDLL .rdata 0x220080) from ids it already
holds. It was being answered with the definition body, which echoes the queried
id straight back. Asked about the active home kit, instance 100004874, the server
replied:

    resourceId 100004874, cardsubtypeid 0, itemType "player", itemState "free"

i.e. "your active home kit is a free player". No kit can ever be seen as active
through that. Real players were equally wrong: instance 100000003 came back as
resourceId 100000003 instead of the actual card 200389 rating 87.

This is on the active-kit path, and the evidence for that is the client's own UI.
KitAssignmentPopup.BIG (exported from Frosty today) decompiles to
external.ion_fut.components.KitAssignmentPopup and contains OSDKCards_ViewCards,
OSDKCards_ActivateCard, mHomeKitID/mAwayKitID/mSourceKitID, and the search states
SEARCH_STATE_ACTIVE_HOME_KIT / SEARCH_STATE_ACTIVE_AWAY_KIT. Those states can
only come from the itemState this route returns.

Route::ViewCards is now distinct from Route::ItemDefs. Owned instances are shaped
by the SAME projector /club uses, so there is one wire dialect and no drift; ids
that are not owned instances still fall back to the definition placeholder, and
the empty query still answers {"itemData": []}, preserving oracle parity for the
definition-style callers (item/resource, defid).

Verified on staging:
  ?idList=100004874,100004873 -> resourceId 6300006/6400003, cardsubtypeid 9,
        itemType kit, itemState activeHomeKit/activeAwayKit, teamid 21, cat 2/3
  ?idList=100000003           -> resourceId 200389, rating 87 (the real card)
  ?idList=999999999           -> definition fallback
  no query                    -> {"itemData": []}
  item/resource? and defid?   -> unchanged

cargo test -p openfut-utas-host: 190 passed, 0 failed. clippy -D warnings clean.
This commit is contained in:
funman300
2026-08-23 21:20:07 +00:00
parent 9cc5188e5c
commit d4a39ba75d
+120 -16
View File
@@ -188,6 +188,12 @@ pub enum Route {
/// the oracle's `defs_route`/`item_def`. The client renders the real card from /// the oracle's `defs_route`/`item_def`. The client renders the real card from
/// its LOCAL DB, so a valid-shaped placeholder is exact parity. /// its LOCAL DB, so a valid-shaped placeholder is exact parity.
ItemDefs, ItemDefs,
/// `GET …/item` — FutViewCards. DISTINCT from [`Route::ItemDefs`]: the client
/// builds `?idList=%lld` from OWNED INSTANCE ids and expects the owned items
/// back, carrying their real `resourceId`, `cardsubtypeid` and `itemState`.
/// Answering it with definition placeholders tells the client its active kit
/// is a free player. See [`UtasHost::handle_view_cards`].
ViewCards,
/// `GET …/marketdata` and `…/marketdata/pricelimits` — suggested pricing. /// `GET …/marketdata` and `…/marketdata/pricelimits` — suggested pricing.
/// `/pricelimits` MUST be a bare ARRAY (one `{defId,minPrice,maxPrice}` per /// `/pricelimits` MUST be a bare ARRAY (one `{defId,minPrice,maxPrice}` per
/// queried defId); plain `/marketdata` MUST be an OBJECT `{minPrice,maxPrice}`. /// queried defId); plain `/marketdata` MUST be an OBJECT `{minPrice,maxPrice}`.
@@ -297,27 +303,25 @@ pub fn classify(method: &str, path: &str) -> Route {
} }
Some(t) if get && (t == "defid" || t.starts_with("defid?")) => Route::ItemDefs, Some(t) if get && (t == "defid" || t.starts_with("defid?")) => Route::ItemDefs,
// `GET ut/%s/item` is FutViewCards (deser `0x1801293d0`, top-level // `GET ut/%s/item` is FutViewCards (deser `0x1801293d0`, top-level
// `itemData` via the shared card element `0x18013fe00`). The oracle // `itemData` via the shared card element `0x18013fe00`). It is NOT the
// answers it with `defs_route` — the SAME handler as `item/resource` and // definition lookup, and answering it as one is a real defect — see
// `defid`: parse every integer out of the query (`idList=a,b,c`, // [`Self::handle_view_cards`]. The client builds it as `?idList=%lld`
// `definitionId=`, `resourceId=`) and return one definition per id, or // (CardsDLL `.rdata` 0x220080) carrying OWNED INSTANCE ids.
// `{"itemData": []}` when the query carries none
// (`tools/utas_server.py:1103`). Claiming it is byte-identical parity.
// //
// It was unclaimed, so it fell through to the Python upstream. That is // It was unclaimed entirely until 2026-08-23, so it fell through to the
// invisible in production, where the oracle answers, and shows up only on // Python upstream: invisible in production where the oracle answers, and a
// staging as a 502 — the same dead-route class already found four times // 502 on staging — the same dead-route class already found four times
// (season/list, watchList, the handle_static_ack tails, tournament/user). // (season/list, watchList, the handle_static_ack tails, tournament/user).
// //
// The `?` forms are not decoration: `ut_tail` does NOT strip the query, so // The `?` forms are not decoration: `ut_tail` does NOT strip the query, so
// a bare equality arm silently misses every real request, which is exactly // a bare equality arm silently misses every real request while passing a
// how this route stayed unclaimed. The oracle's own pattern is // no-query unit test. That is how this stayed unclaimed. The oracle's own
// `item(\?|$)` (`tools/utas_server.py:1419`) and this mirrors it. // pattern is `item(\?|$)` (`tools/utas_server.py:1419`).
// //
// MUST stay below `item/resource` (matched first) and must not swallow // MUST stay below `item/resource` (matched first) and must not swallow
// `item/<id>`, which is DELETE-only Quick Sell, nor PUT `item`, which is // `item/<id>`, which is DELETE-only Quick Sell, nor PUT `item`, which is
// FutMoveCard on the economy path. // FutMoveCard on the economy path.
Some(t) if get && (t == "item" || t.starts_with("item?")) => Route::ItemDefs, Some(t) if get && (t == "item" || t.starts_with("item?")) => Route::ViewCards,
Some(t) if get && (t == "marketdata" || t.starts_with("marketdata/")) => Route::MarketData, Some(t) if get && (t == "marketdata" || t.starts_with("marketdata/")) => Route::MarketData,
_ => Route::Passthrough, _ => Route::Passthrough,
} }
@@ -4351,6 +4355,7 @@ impl Server {
Route::FeatureOffEmpty => self.handle_feature_off_empty(path), Route::FeatureOffEmpty => self.handle_feature_off_empty(path),
Route::Season => self.handle_season(path), Route::Season => self.handle_season(path),
Route::ItemDefs => self.handle_item_defs(target), Route::ItemDefs => self.handle_item_defs(target),
Route::ViewCards => self.handle_view_cards(target),
Route::MarketData => self.handle_marketdata(path, target), Route::MarketData => self.handle_marketdata(path, target),
Route::SecurityQuestion => self.handle_security_question(method, target, headers), Route::SecurityQuestion => self.handle_security_question(method, target, headers),
Route::Passthrough => self.passthrough(method, target, headers, body), Route::Passthrough => self.passthrough(method, target, headers, body),
@@ -4983,6 +4988,105 @@ impl Server {
json_status(200, &non_economy::item_defs_body(&ids)) json_status(200, &non_economy::item_defs_body(&ids))
} }
/// `GET …/item?idList=…` — FutViewCards.
///
/// The ids here are OWNED INSTANCE ids, not definition ids. The client builds
/// the query as `?idList=%lld` (CardsDLL `.rdata` `0x220080`) from ids it
/// already holds, and reads the returned items' real fields back.
///
/// This is on the active-kit path. The FUT "Assign Kit" popup
/// (`external.ion_fut.components.KitAssignmentPopup`, recovered from
/// `KitAssignmentPopup.BIG`) drives `OSDKCards_ViewCards` and
/// `OSDKCards_ActivateCard`, tracks `mHomeKitID`/`mAwayKitID`, and filters its
/// local card inventory by `SEARCH_STATE_ACTIVE_HOME_KIT` /
/// `SEARCH_STATE_ACTIVE_AWAY_KIT`. Those states can only come from the
/// `itemState` this route returns.
///
/// THE DEFECT THIS FIXES: answering with the definition body
/// ([`Self::handle_item_defs`]) echoes the queried id back as `resourceId` and
/// emits `cardsubtypeid: 0`, `itemType: "player"`, `itemState: "free"`. Asked
/// about the active home kit (instance 100004874) the server replied that it
/// was a free player — so no kit can ever be seen as active. Measured on
/// staging 2026-08-23.
///
/// Owned instances are shaped by the SAME projector `/club` uses, so a kit
/// carries `resourceId` 6300006, `cardsubtypeid` 9, `itemState`
/// `activeHomeKit`, `teamid`, `category` and `year` exactly as it does there —
/// one shaping, no second wire dialect to drift.
///
/// Ids that are NOT owned instances fall back to the definition placeholder,
/// preserving the oracle's behaviour for definition-style queries and for the
/// empty query (`{"itemData": []}`).
fn handle_view_cards(&self, target: &str) -> WireResponse {
let query = target.split_once('?').map(|(_, q)| q).unwrap_or("");
let ids = extract_long_ints(query);
if ids.is_empty() {
eprintln!("utas-host owner=RUST route=view-cards count=0 owned=0 status=200");
return json_status(200, &json!({ "itemData": [] }));
}
// Shape the whole owned set once with the club projector, then select the
// requested instances from it. Selecting first is not possible: the wire
// instance id is assigned BY the projector, so there is nothing to match
// against until the items are shaped.
let wanted: std::collections::HashSet<i64> = ids.iter().copied().collect();
let mut owned: Vec<Value> = Vec::new();
if let Ok(page) = self.core.query_owned(&[]) {
let hidden = self.club_hidden_ids();
let active_kits = self.core.get_active_kits().unwrap_or_default();
let visible: Vec<CoreOwnedItem> = page
.items
.into_iter()
.filter(|item| !hidden.contains(&item.owned_card_id))
.collect();
let (body, _) = shape_club_response_with_kits(
&visible,
self.entities.as_ref(),
self.resolver.as_ref(),
ActiveKitAssignments {
home: active_kits.home_owned_card_id.as_deref(),
away: active_kits.away_owned_card_id.as_deref(),
},
);
owned = body
.get("itemData")
.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter(|it| {
it.get("id")
.and_then(Value::as_i64)
.is_some_and(|id| wanted.contains(&id))
})
.cloned()
.collect()
})
.unwrap_or_default();
}
let found: std::collections::HashSet<i64> = owned
.iter()
.filter_map(|it| it.get("id").and_then(Value::as_i64))
.collect();
let missing: Vec<i64> = ids.into_iter().filter(|id| !found.contains(id)).collect();
let mut items = owned;
if !missing.is_empty() {
if let Some(defs) = non_economy::item_defs_body(&missing)
.get("itemData")
.and_then(Value::as_array)
{
items.extend(defs.iter().cloned());
}
}
eprintln!(
"utas-host owner=RUST route=view-cards count={} owned={} defs={} status=200",
items.len(),
found.len(),
missing.len()
);
json_status(200, &json!({ "itemData": items }))
}
/// `POST …/item/resource/<resourceId>` — apply one consumable to one owned /// `POST …/item/resource/<resourceId>` — apply one consumable to one owned
/// target (`ApplyCardByRes`, task id `0x0e`), live-captured 2026-08-21 as /// target (`ApplyCardByRes`, task id `0x0e`), live-captured 2026-08-21 as
/// `{"apply":[{"id":<target>}]}`. /// `{"apply":[{"id":<target>}]}`.
@@ -7080,13 +7184,13 @@ mod tests {
#[test] #[test]
fn get_item_is_claimed_and_the_other_item_verbs_are_unaffected() { fn get_item_is_claimed_and_the_other_item_verbs_are_unaffected() {
// FutViewCards, with and without the definition query the client builds. // FutViewCards, with and without the definition query the client builds.
assert_eq!(classify("GET", "/ut/game/fifa17/item"), Route::ItemDefs); assert_eq!(classify("GET", "/ut/game/fifa17/item"), Route::ViewCards);
assert_eq!( assert_eq!(
classify("GET", "/ut/game/fifa17/item?idList=100000003,100000004"), classify("GET", "/ut/game/fifa17/item?idList=100000003,100000004"),
Route::ItemDefs Route::ViewCards
); );
// v2 sku form resolves identically. // v2 sku form resolves identically.
assert_eq!(classify("GET", "/ut/v2/game/fifa17/item"), Route::ItemDefs); assert_eq!(classify("GET", "/ut/v2/game/fifa17/item"), Route::ViewCards);
// The more specific definition routes still win, and — the bug this test // The more specific definition routes still win, and — the bug this test
// exists for — they must survive a query string too. `ut_tail` does not // exists for — they must survive a query string too. `ut_tail` does not
// strip the query, so an equality-only arm misses every real request while // strip the query, so an equality-only arm misses every real request while