feat(host): staging-only consumable-apply probe; reverse the success contract

Claims POST ut/<sku>/item/resource/<resourceId> -- the consumable apply captured
live 2026-08-21 -- behind OPENFUT_FIFA17_APPLY_PROBE=1, default OFF. With the
gate off the route takes the extracted `passthrough` method, i.e. byte-for-byte
the behaviour that existed before this commit, so production cannot serve a
diagnostic even if the route is reached.

The handler is NON-AUTHORITATIVE BY CONSTRUCTION: it consumes no source card,
mutates no target, touches no contract/fitness/chemistry/training/injury state,
mints no coins and changes no ownership. It exists only to observe the client's
success path, because the EFFECT of a consumable is still unreversed and
implementing one on an inferred value is not acceptable.

RESPONSE SHAPE, from static RE rather than convenience (the brief was explicit
that `{}` must not be chosen because it is easy):

  * The apply completion handler is CardsDLL 0x180035520. It does
    `mov ecx,[rdx+0x1c]; test ecx,ecx; jne FAILURE`, raising
    EVENT_CARDS_APPLY_CARD_SUCCESS (0x1801f37f0) on zero and
    EVENT_CARDS_APPLY_CARD_FAILURE (0x1801f3810) otherwise. It tests exactly one
    field -- the transport code -- and never inspects the body.
  * That is materially different from the MOVE ack (0x180128600), which builds
    per-item verdict records and reports FAILURE when the vector is EMPTY. The
    `{}`-is-broken precedent does not transfer.
  * The response object's constructor (0x1800a4ce0) initialises its record vector
    (+0x50/+0x58/+0x60, 0x20-byte elements) EMPTY, so an empty parse result is a
    legal state here, and the destructor (0x1800682b0) frees it accordingly.
  * The legacy oracle routes `item/resource` method-agnostically to defs_route,
    so historically this path answered with an `itemData` OBJECT.

`{"itemData":[]}` is the smallest candidate consistent with all four, and it is
labelled a PROBE, not a proven contract.

`apply` is an array, but only len==1 has ever been observed, so a multi-target
request is logged and refused (400 apply_batch_unsupported) rather than given
invented batch semantics.

Operands are identified READ-ONLY for the capture: the source by Core card id
(`<sku>_<resourceId>`, no new resolver method for a probe) with a copy count, the
target by reversing the wire id through the identity store -- never a guess,
`UNRESOLVED_WIRE_ID` when unknown.

Also records the reversed protocol and the `development` finding in
CLIENT_ROUTE_SURFACE.md.

122 host tests (+2: the verb/resource-id classification boundary, and target
parsing incl. the exact captured bytes). clippy and fmt clean.
This commit is contained in:
funman300
2026-08-22 00:49:23 +00:00
parent 6ca735749e
commit ce5d4204ac
+233 -44
View File
@@ -186,6 +186,17 @@ pub enum Route {
/// Container type is load-bearing (object-where-array froze a live client);
/// the handler picks it from the path. Constant band 150..15000.
MarketData,
/// `POST …/item/resource/<resourceId>` — consumable APPLICATION
/// (`ApplyCardByRes`, task id `0x0e`), captured live 2026-08-21 as
/// `{"apply":[{"id":<target>}]}`. The source consumable is the RESOURCE id in
/// the path; the targets are owned-item wire ids in the body.
///
/// This classifies unconditionally so the route table stays a pure function of
/// (method, path) and remains testable, but the handler is a STAGING-ONLY
/// DIAGNOSTIC: without `OPENFUT_FIFA17_APPLY_PROBE=1` it declines and the
/// request falls through to the Python passthrough exactly as it does today.
/// The effect of a consumable is UNREVERSED, so nothing is ever mutated here.
ConsumableApplyProbe,
/// Anything else — proxied verbatim to the Python oracle.
Passthrough,
}
@@ -285,6 +296,16 @@ pub fn classify(method: &str, path: &str) -> Route {
Some("clubUser") if get => Route::FeatureOffEmpty,
Some("user/list") if get => Route::FeatureOffEmpty,
Some("item/resource") if get => Route::ItemDefs,
// The apply re-uses the item-definition PATH with a different VERB and a
// trailing resource id, which is why it fell through to Python: the
// `item/resource` arm above is GET-only. Live-captured 2026-08-21.
Some(t)
if post
&& t.strip_prefix("item/resource/")
.is_some_and(|r| !r.is_empty() && r.bytes().all(|b| b.is_ascii_digit())) =>
{
Route::ConsumableApplyProbe
}
Some("defid") if get => Route::ItemDefs,
Some(t) if get && (t == "marketdata" || t.starts_with("marketdata/")) => Route::MarketData,
_ => Route::Passthrough,
@@ -4019,55 +4040,69 @@ impl Server {
Route::FeatureOffEmpty => self.handle_feature_off_empty(path),
Route::Season => self.handle_season(path),
Route::ItemDefs => self.handle_item_defs(target),
Route::ConsumableApplyProbe => {
match self.handle_consumable_apply_probe(target, body) {
Some(resp) => resp,
// Gate off: identical to today — proxy it verbatim.
None => self.passthrough(method, target, headers, body),
}
}
Route::MarketData => self.handle_marketdata(path, target),
Route::SecurityQuestion => self.handle_security_question(method, target, headers),
Route::Passthrough => {
// Name the request BEFORE forwarding. On staging the upstream is
// deliberately dead, so this line is the only record of what the
// client asked for -- which is exactly how an unclaimed route is
// discovered (see docs/CLIENT_ROUTE_SURFACE.md).
eprintln!(
"utas-host owner=PYTHON route=passthrough method={method} path={target} body_len={}",
body.len()
);
// The BODY is what identifies an unknown mutation's operands, but
// it is also the one place a request can carry something we should
// not write to a log, so it is opt-in and capped. Staging probe
// only: OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1.
if passthrough_body_logging() && !body.is_empty() {
let cap = body.len().min(512);
eprintln!(
"utas-host PASSTHROUGH-BODY path={target} bytes={} body={}",
body.len(),
String::from_utf8_lossy(&body[..cap])
);
}
let resp = match self.pass.forward(method, target, headers, body) {
Ok(r) => r,
Err(e) => {
eprintln!(
"utas-host ERROR passthrough failed method={method} path={target}: {e}"
);
WireResponse {
status: 502,
headers: vec![(
"Content-Type".to_string(),
"application/json".to_string(),
)],
body: br#"{"error":"upstream unavailable"}"#.to_vec(),
transport: ResponseTransport::Normal,
}
}
};
eprintln!(
"utas-host owner=PYTHON_FALLBACK method={} path={} status={}",
method, path, resp.status
);
resp
}
Route::Passthrough => self.passthrough(method, target, headers, body),
}
}
/// Proxy a request verbatim to the Python oracle. Extracted so the declined
/// consumable-apply probe takes EXACTLY this path — with the gate off there is
/// no behavioural difference from before the probe existed.
fn passthrough(
&self,
method: &str,
target: &str,
headers: &[(String, String)],
body: &[u8],
) -> WireResponse {
let path = target.split('?').next().unwrap_or(target);
// Name the request BEFORE forwarding. On staging the upstream is
// deliberately dead, so this line is the only record of what the
// client asked for -- which is exactly how an unclaimed route is
// discovered (see docs/CLIENT_ROUTE_SURFACE.md).
eprintln!(
"utas-host owner=PYTHON route=passthrough method={method} path={target} body_len={}",
body.len()
);
// The BODY is what identifies an unknown mutation's operands, but
// it is also the one place a request can carry something we should
// not write to a log, so it is opt-in and capped. Staging probe
// only: OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1.
if passthrough_body_logging() && !body.is_empty() {
let cap = body.len().min(512);
eprintln!(
"utas-host PASSTHROUGH-BODY path={target} bytes={} body={}",
body.len(),
String::from_utf8_lossy(&body[..cap])
);
}
let resp = match self.pass.forward(method, target, headers, body) {
Ok(r) => r,
Err(e) => {
eprintln!("utas-host ERROR passthrough failed method={method} path={target}: {e}");
WireResponse {
status: 502,
headers: vec![("Content-Type".to_string(), "application/json".to_string())],
body: br#"{"error":"upstream unavailable"}"#.to_vec(),
transport: ResponseTransport::Normal,
}
}
};
eprintln!(
"utas-host owner=PYTHON_FALLBACK method={} path={} status={}",
method, path, resp.status
);
resp
}
/// Monotonic seconds since server start — the clock for session/pending TTLs.
fn now(&self) -> f64 {
self.start.elapsed().as_secs_f64()
@@ -4644,6 +4679,88 @@ impl Server {
json_status(200, &non_economy::item_defs_body(&ids))
}
/// `POST …/item/resource/<resourceId>` — STAGING-ONLY consumable-apply
/// diagnostic. Returns `None` (→ Python passthrough, today's behaviour) unless
/// `OPENFUT_FIFA17_APPLY_PROBE=1`.
///
/// NON-AUTHORITATIVE BY CONSTRUCTION. It consumes no source card, mutates no
/// target, touches no contract/fitness/chemistry/training/injury state, mints
/// no coins and changes no ownership. It exists to observe what the client
/// does with a success, because the EFFECT of a consumable is unreversed and
/// implementing one on an inferred value is not acceptable.
///
/// RESPONSE SHAPE, from static RE rather than convenience: the apply
/// completion handler (CardsDLL `0x180035520`) tests exactly one field,
/// `[obj+0x1c]`, and raises `EVENT_CARDS_APPLY_CARD_SUCCESS` when it is zero,
/// `EVENT_CARDS_APPLY_CARD_FAILURE` otherwise. It never inspects the body —
/// unlike the move ack (`0x180128600`), which builds per-item verdict records
/// and fails on an EMPTY vector. The response object's constructor
/// (`0x1800a4ce0`) initialises its record vector EMPTY, so empty is a legal
/// parse result here. `{"itemData":[]}` is therefore the smallest candidate
/// consistent with both the client and the oracle, whose `item/resource` route
/// is method-agnostic and answers this path with an `itemData` object.
/// It is a PROBE, not a proven contract.
fn handle_consumable_apply_probe(&self, target: &str, body: &[u8]) -> Option<WireResponse> {
if !apply_probe_enabled() {
return None;
}
let path = target.split('?').next().unwrap_or(target);
let resource_id: i64 = path.rsplit('/').next().and_then(|s| s.parse().ok())?;
let targets = parse_apply_targets(body);
// `apply` is an ARRAY, but only len==1 has ever been observed. Batch
// semantics (atomic? partial?) are unknown, so a multi-target request is
// reported and refused rather than guessed at.
if targets.len() != 1 {
eprintln!(
"utas-host owner=RUST route=apply-probe status=refused resource={resource_id} \
targets={} reason=batch_semantics_unproven body={}",
targets.len(),
String::from_utf8_lossy(&body[..body.len().min(256)])
);
return Some(error_response(400, "apply_batch_unsupported"));
}
// Read-only identification of both operands, so the capture names what was
// applied to what. No write path is reachable from here.
let owned = self.core.all_owned().unwrap_or_default();
// A Core card id is "<sku>_<resourceId>", so the path's resource id names
// the DEFINITION directly; no new resolver method is needed for a probe.
let is_source = |it: &CoreOwnedItem| {
it.card_id
.rsplit_once('_')
.and_then(|(_, n)| n.parse::<i64>().ok())
== Some(resource_id)
};
let copies = owned.iter().filter(|it| is_source(it)).count();
let source_desc = match owned.iter().find(|it| is_source(it)) {
Some(it) => format!(
"owned kind={:?} subtype={} copies={}",
self.resolver.kind_of(it),
self.resolver.subtype_of(it),
copies
),
None => "NOT_OWNED".to_string(),
};
// Reverse the wire id through the identity store -- never a guess.
let target_desc = match self.resolver.owned_id_for_wire(targets[0]) {
Some(core_id) => match owned.iter().find(|it| it.owned_card_id == core_id) {
Some(it) => format!(
"owned card={} rating={} kind={:?}",
it.card_id,
it.rating,
self.resolver.kind_of(it)
),
None => format!("known_wire_id={core_id} NOT_IN_CLUB"),
},
None => "UNRESOLVED_WIRE_ID".to_string(),
};
eprintln!(
"utas-host owner=RUST route=apply-probe status=200 PROBE_ONLY resource={resource_id} \
source={source_desc} target={} target_item={target_desc} mutated=NOTHING",
targets[0]
);
Some(json_text_status(200, "{\"itemData\":[]}".to_string()))
}
/// `GET …/marketdata[/pricelimits]` — suggested pricing. `/pricelimits` returns
/// the bare ARRAY (one band per queried defId); any other `/marketdata` returns
/// the OBJECT band. Container type is chosen from the path (load-bearing: the
@@ -4876,6 +4993,34 @@ fn commerce_settings_enabled() -> bool {
*ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_COMMERCE_SETTINGS").as_deref() == Ok("1"))
}
/// Wire ids from a consumable-apply body: `{"apply":[{"id":N}, …]}`.
///
/// Captured live 2026-08-21. Only `len == 1` has ever been observed; the caller
/// refuses anything else rather than invent batch semantics.
fn parse_apply_targets(body: &[u8]) -> Vec<i64> {
serde_json::from_slice::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("apply").and_then(|a| a.as_array()).cloned())
.map(|a| {
a.iter()
.filter_map(|e| e.get("id").and_then(|i| i.as_i64()))
.collect()
})
.unwrap_or_default()
}
/// Whether the STAGING-ONLY consumable-apply diagnostic answers.
///
/// OFF unless `OPENFUT_FIFA17_APPLY_PROBE=1`. With it off the route falls through
/// to the Python passthrough, i.e. byte-for-byte today's behaviour, so production
/// cannot accidentally serve a diagnostic. The probe exists ONLY to observe the
/// client's success path: the consumable EFFECT is unreversed, so it consumes
/// nothing and mutates nothing.
fn apply_probe_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| std::env::var("OPENFUT_FIFA17_APPLY_PROBE").as_deref() == Ok("1"))
}
/// Whether to log unclaimed (passthrough) request BODIES.
///
/// OFF unless `OPENFUT_FIFA17_LOG_PASSTHROUGH_BODY=1`, and capped at 512 bytes.
@@ -6254,6 +6399,50 @@ mod tests {
.is_empty());
}
/// The apply re-uses the definition-lookup PATH with a different VERB, which
/// is exactly why it went unclaimed. Lock that boundary.
#[test]
fn consumable_apply_is_classified_by_verb_and_resource_id() {
// Live-captured 2026-08-21: POST ut/<sku>/item/resource/<resourceId>.
assert_eq!(
classify("POST", "/ut/game/fifa17/item/resource/5001004"),
Route::ConsumableApplyProbe
);
assert_eq!(
classify("POST", "/ut/v2/game/fifa17/item/resource/5001004"),
Route::ConsumableApplyProbe
);
// A non-numeric tail is not a resource id, so it is not the apply.
assert_eq!(
classify("POST", "/ut/game/fifa17/item/resource/bogus"),
Route::Passthrough
);
// The definition lookup keeps the path under its own verb.
assert_eq!(
classify("GET", "/ut/game/fifa17/item/resource"),
Route::ItemDefs
);
}
#[test]
fn apply_targets_parse_from_the_captured_body() {
// The exact bytes the client sent, 2026-08-21.
assert_eq!(
parse_apply_targets(br#"{"apply":[{"id":100000003}]}"#),
vec![100000003]
);
// A batch is parsed but the handler refuses it: semantics unproven.
assert_eq!(
parse_apply_targets(br#"{"apply":[{"id":1},{"id":2}]}"#),
vec![1, 2]
);
// Never invent a target.
assert!(parse_apply_targets(b"").is_empty());
assert!(parse_apply_targets(br#"{"apply":[]}"#).is_empty());
assert!(parse_apply_targets(br#"{"nope":[{"id":7}]}"#).is_empty());
assert!(parse_apply_targets(br#"{"apply":[{"noid":7}]}"#).is_empty());
}
#[test]
fn marketdata_container_types_are_load_bearing() {
// /pricelimits MUST be a bare ARRAY (object-where-array froze a live client).