Summary
GameObject::base_name is never cleared by the hidden-information filter, so every card in a hidden zone ships its real printed name to every opponent over the wire. A client that reads base_name instead of name sees the full contents of opponents' hands and libraries.
This is a hidden-information violation, not a display bug. Per CR 400.2: "Hidden zones are zones in which not all players can be expected to see the cards' faces. Library and hand are hidden zones, even if all the cards in one such zone happen to be revealed."
Evidence
Verified at a700239ef, in crates/engine/src/game/:
-
Every object's base_name is its real name. game_object.rs:2190 — GameObject::new constructs with base_name: name.clone(). There is also a backfill at :2077-2078 (if self.base_name.is_empty() && !self.name.is_empty() { self.base_name = self.name.clone(); }), so an empty base_name refills itself.
-
hide_card redacts name but not base_name. visibility.rs::hide_card sets obj.name = HIDDEN_CARD_NAME and clears abilities, keywords, base_keywords, power, toughness, loyalty, color, base_color, trigger_definitions, replacement_definitions, static_definitions, casting_permissions, printed_ref, base_printed_ref, back_face, token_image_ref, source_related_token_ids, foretold. base_name is absent from that list.
-
base_name is serialized. Declared #[serde(default)] pub base_name: String with no skip_serializing_if, so it is present in the filtered payload sent to each viewer.
-
base_name is load-bearing, not vestigial — game_object.rs:2465 restores self.name = self.base_name.clone(), which is why it holds the true name (it is the revert target for temporary name changes such as copy effects).
Impact
An opponent's client receives, for each card in your hand and library, an object whose name reads "Hidden Card" while base_name carries the real card name. That is complete knowledge of hidden zones — hand contents, library contents, and by extension your remaining outs. It applies to every hide_card call site.
Root cause — the reason this will recur
hide_card is an allowlist-shaped problem implemented as a denylist: it enumerates fields to clear rather than projecting a minimal object. Any field added to GameObject therefore defaults to leaked, and nothing in the type system or test suite objects.
Note specifically that _gameobject_partition_is_total does not protect against this. That guard forces every new field into a serialization classification; it says nothing about visibility. A field can pass the partition guard and still leak.
Other fields with the same defect
Measured on the wire (sentinels set on a hidden card, filter_state_for_viewer → serde_json::to_string) during the work described below. base_name I re-verified directly from source as above; the rest were observed leaking and are all likewise absent from hide_card's clear list:
| field |
note |
base_name |
real card name — the sharpest one |
spellbook |
list of card names |
unimplemented_mechanics |
per-card mechanic fingerprint |
token_rules_text |
|
base_power / base_toughness |
sentinel values survived redaction |
Additionally base_card_types, base_mana_cost, base_abilities, base_*_definitions, base_loyalty, base_defense, card_id, mana_cost, card_types are serialized and uncleared; they were empty in the synthetic probe, but apply_card_face_to_object populates them from the real card face in a live game, so they should be treated as suspect until measured.
Frontend blast radius (for triage): base_name and spellbook have zero frontend consumers; unimplemented_mechanics has three. So clearing the two sharpest fields is unlikely to affect rendering.
Steps to reproduce
- Build a state where P1 has cards in hand.
let filtered = filter_state_for_viewer(&state, P2);
- Inspect any P1 hand object:
name == "Hidden Card", but base_name is the real card name.
serde_json::to_string(&filtered) — the real name is in the payload.
Suggested fix
Clearing base_name alone is a one-line stopgap and is worth doing immediately, but it leaves the class open. The structural fix is to project a minimal object for hidden cards — construct the redacted view from an explicit whitelist of fields a non-viewer is allowed to see — so that a newly added field defaults to hidden rather than leaked. That change wants its own test matrix plus a client-consumer audit, which is why this is filed separately rather than bundled.
Whichever route, the regression should be a matched pair: the field is empty for a non-viewer and populated for the owner. Asserting only the empty case passes vacuously if the field was never populated in the fixture.
Worth auditing the sibling redactor redact_face_down_identity_from_observer in the same pass — it had the identical omission for a different field (see below).
Provenance
Found while addressing review feedback on #7101. That PR added a parse_warnings field to GameObject and it leaked by exactly this mechanism — an opponent received name: "Hidden Card" alongside diagnostics containing the card's verbatim rules text. #7101 fixes its own regression in both hide_card and redact_face_down_identity_from_observer; the pre-existing fields listed above are deliberately not fixed there, because the correct repair is the structural one described above.
Related
Related to #6732 (Face down cards largely unplayable). Both are defects in the same face-down / hidden-information redaction layer, in opposite directions: #6732 reports face-down cards being unreadable to the player who should be able to identify them, while this issue is hidden cards being readable by players who should not. Taken together they suggest the redaction seam needs an explicit per-viewer visibility contract rather than incremental field-by-field adjustment.
Summary
GameObject::base_nameis never cleared by the hidden-information filter, so every card in a hidden zone ships its real printed name to every opponent over the wire. A client that readsbase_nameinstead ofnamesees the full contents of opponents' hands and libraries.This is a hidden-information violation, not a display bug. Per CR 400.2: "Hidden zones are zones in which not all players can be expected to see the cards' faces. Library and hand are hidden zones, even if all the cards in one such zone happen to be revealed."
Evidence
Verified at
a700239ef, incrates/engine/src/game/:Every object's
base_nameis its real name.game_object.rs:2190—GameObject::newconstructs withbase_name: name.clone(). There is also a backfill at:2077-2078(if self.base_name.is_empty() && !self.name.is_empty() { self.base_name = self.name.clone(); }), so an emptybase_namerefills itself.hide_cardredactsnamebut notbase_name.visibility.rs::hide_cardsetsobj.name = HIDDEN_CARD_NAMEand clearsabilities,keywords,base_keywords,power,toughness,loyalty,color,base_color,trigger_definitions,replacement_definitions,static_definitions,casting_permissions,printed_ref,base_printed_ref,back_face,token_image_ref,source_related_token_ids,foretold.base_nameis absent from that list.base_nameis serialized. Declared#[serde(default)] pub base_name: Stringwith noskip_serializing_if, so it is present in the filtered payload sent to each viewer.base_nameis load-bearing, not vestigial —game_object.rs:2465restoresself.name = self.base_name.clone(), which is why it holds the true name (it is the revert target for temporary name changes such as copy effects).Impact
An opponent's client receives, for each card in your hand and library, an object whose
namereads"Hidden Card"whilebase_namecarries the real card name. That is complete knowledge of hidden zones — hand contents, library contents, and by extension your remaining outs. It applies to everyhide_cardcall site.Root cause — the reason this will recur
hide_cardis an allowlist-shaped problem implemented as a denylist: it enumerates fields to clear rather than projecting a minimal object. Any field added toGameObjecttherefore defaults to leaked, and nothing in the type system or test suite objects.Note specifically that
_gameobject_partition_is_totaldoes not protect against this. That guard forces every new field into a serialization classification; it says nothing about visibility. A field can pass the partition guard and still leak.Other fields with the same defect
Measured on the wire (sentinels set on a hidden card,
filter_state_for_viewer→serde_json::to_string) during the work described below.base_nameI re-verified directly from source as above; the rest were observed leaking and are all likewise absent fromhide_card's clear list:base_namespellbookunimplemented_mechanicstoken_rules_textbase_power/base_toughnessAdditionally
base_card_types,base_mana_cost,base_abilities,base_*_definitions,base_loyalty,base_defense,card_id,mana_cost,card_typesare serialized and uncleared; they were empty in the synthetic probe, butapply_card_face_to_objectpopulates them from the real card face in a live game, so they should be treated as suspect until measured.Frontend blast radius (for triage):
base_nameandspellbookhave zero frontend consumers;unimplemented_mechanicshas three. So clearing the two sharpest fields is unlikely to affect rendering.Steps to reproduce
let filtered = filter_state_for_viewer(&state, P2);name == "Hidden Card", butbase_nameis the real card name.serde_json::to_string(&filtered)— the real name is in the payload.Suggested fix
Clearing
base_namealone is a one-line stopgap and is worth doing immediately, but it leaves the class open. The structural fix is to project a minimal object for hidden cards — construct the redacted view from an explicit whitelist of fields a non-viewer is allowed to see — so that a newly added field defaults to hidden rather than leaked. That change wants its own test matrix plus a client-consumer audit, which is why this is filed separately rather than bundled.Whichever route, the regression should be a matched pair: the field is empty for a non-viewer and populated for the owner. Asserting only the empty case passes vacuously if the field was never populated in the fixture.
Worth auditing the sibling redactor
redact_face_down_identity_from_observerin the same pass — it had the identical omission for a different field (see below).Provenance
Found while addressing review feedback on #7101. That PR added a
parse_warningsfield toGameObjectand it leaked by exactly this mechanism — an opponent receivedname: "Hidden Card"alongside diagnostics containing the card's verbatim rules text. #7101 fixes its own regression in bothhide_cardandredact_face_down_identity_from_observer; the pre-existing fields listed above are deliberately not fixed there, because the correct repair is the structural one described above.Related
Related to #6732 (Face down cards largely unplayable). Both are defects in the same face-down / hidden-information redaction layer, in opposite directions: #6732 reports face-down cards being unreadable to the player who should be able to identify them, while this issue is hidden cards being readable by players who should not. Taken together they suggest the redaction seam needs an explicit per-viewer visibility contract rather than incremental field-by-field adjustment.