diff --git a/client/src/adapter/types.ts b/client/src/adapter/types.ts
index eb0ef649a7..906798145c 100644
--- a/client/src/adapter/types.ts
+++ b/client/src/adapter/types.ts
@@ -795,6 +795,18 @@ export interface TokenCharacteristics {
keywords: Keyword[];
}
+/**
+ * Which keyword action put a permanent onto the battlefield face down
+ * (engine `FaceDownCause`). Only meaningful while `face_down` is true.
+ * `TurnedFaceDown` is the Ixidron class, for which no marker token is printed.
+ */
+export type FaceDownCause =
+ | "Manifest"
+ | "Morph"
+ | "Cloak"
+ | "Disguise"
+ | "TurnedFaceDown";
+
export interface TokenImageRef {
scryfall_id: string;
scryfall_oracle_id?: string | null;
@@ -1008,6 +1020,8 @@ export interface GameObject {
display_visible_to_viewer?: boolean;
tapped: boolean;
face_down: boolean;
+ /** Set only while `face_down` is true; absent on older saves. */
+ face_down_cause?: FaceDownCause | null;
flipped: boolean;
transformed: boolean;
damage_marked: number;
diff --git a/client/src/components/board/AttachmentFan.tsx b/client/src/components/board/AttachmentFan.tsx
index 53459f2dd1..3b9f717270 100644
--- a/client/src/components/board/AttachmentFan.tsx
+++ b/client/src/components/board/AttachmentFan.tsx
@@ -319,6 +319,7 @@ function FanCard({
tokenImageRef={isToken ? obj.token_image_ref : undefined}
oracleText={isToken ? obj.token_rules_text : undefined}
faceDown={shouldRenderCardBack(obj)}
+ faceDownCause={obj.face_down ? obj.face_down_cause : undefined}
className="!w-[var(--fan-card-w)] !h-[var(--fan-card-h)]"
/>
diff --git a/client/src/components/board/PermanentCard.tsx b/client/src/components/board/PermanentCard.tsx
index 764011ad1c..64a9defa4a 100644
--- a/client/src/components/board/PermanentCard.tsx
+++ b/client/src/components/board/PermanentCard.tsx
@@ -913,7 +913,7 @@ export const PermanentCard = memo(function PermanentCard({
) : (
<>
-
+
{/* CR 702.26: phased-out tint overlay — sky-blue mix-blend-screen
matches the player-area treatment (PlayerArea.tsx 4d6cfb506). */}
{isPhasedOut && (
@@ -1266,7 +1266,7 @@ const ExileGhostCard = memo(function ExileGhostCard({ objectId, offset }: ExileG
{useArtCrop ? (
) : (
-
+
)}
);
diff --git a/client/src/components/card/ArtCropCard.tsx b/client/src/components/card/ArtCropCard.tsx
index d4653e4bef..e631bca1ba 100644
--- a/client/src/components/card/ArtCropCard.tsx
+++ b/client/src/components/card/ArtCropCard.tsx
@@ -8,6 +8,7 @@ import { useIsMobile } from "../../hooks/useIsMobile.ts";
import { isUnbounded, pillsOf, useCounterDisplay } from "../../hooks/useCounterDisplay.ts";
import { cardImageLookup, tokenFiltersForObject } from "../../services/cardImageLookup.ts";
import { CARD_BACK_URL } from "../../services/scryfall.ts";
+import { faceDownMarkerRef } from "./faceDownMarker.ts";
import { useGameStore } from "../../stores/gameStore.ts";
import { useUiStore } from "../../stores/uiStore.ts";
import { COUNTER_COLORS, computePTDisplay, hasOtherPrintedFace, shouldRenderCardBack, toRoman } from "../../viewmodel/cardProps.ts";
@@ -43,12 +44,20 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr
? cardImageLookup(obj)
: { name: "", faceIndex: 0, oracleId: undefined, faceName: undefined };
const isToken = obj?.display_source === "Token";
+ // A face-down permanent shows the marker token for the ability that turned it
+ // face down (Morph / Manifest / A Mysterious Creature), the way paper play
+ // does. Without a marker the card back is rendered exactly as before.
+ const faceDownMarker = faceDownMarkerRef(obj?.face_down ?? false, obj?.face_down_cause);
const { src: cardSrc, isLoading: cardLoading } = useCardImage(renderCardBack ? "" : imageLookup.name, {
size: "art_crop",
faceIndex: imageLookup.faceIndex,
- isToken: renderCardBack ? false : isToken,
+ isToken: renderCardBack ? faceDownMarker !== null : isToken,
tokenFilters: !renderCardBack && isToken && obj ? tokenFiltersForObject(obj) : undefined,
- tokenImageRef: !renderCardBack && isToken && obj ? obj.token_image_ref : undefined,
+ tokenImageRef: renderCardBack
+ ? (faceDownMarker ?? undefined)
+ : isToken && obj
+ ? obj.token_image_ref
+ : undefined,
oracleId: renderCardBack ? undefined : imageLookup.oracleId,
faceName: renderCardBack ? undefined : imageLookup.faceName,
});
@@ -74,7 +83,7 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr
if (!obj) return null;
- const src = renderCardBack ? CARD_BACK_URL : cardSrc;
+ const src = renderCardBack ? (cardSrc ?? CARD_BACK_URL) : cardSrc;
const isLoading = renderCardBack ? false : cardLoading;
// CR 712 vs CR 710: `back_face != null` is NOT "has a second face" — a
// Kamigawa flip card stores its alternative half in the same slot and has no
@@ -110,7 +119,13 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr
);
}
- const renderedSrc = renderCardBack ? CARD_BACK_URL : (src ?? "");
+ // The card back is the fallback in BOTH directions: a marker that never
+ // resolves and a marker URL whose `
` fails to load both land here. The
+ // artless text tile below is for face-UP cards with no printing; a face-down
+ // permanent always has the card back to fall back to.
+ const renderedSrc = renderCardBack
+ ? (artError ? CARD_BACK_URL : (src ?? CARD_BACK_URL))
+ : (src ?? "");
const headerHeight = isCompactHeight
? "clamp(8px, calc(var(--art-crop-h) * 0.16), 12px)"
: "clamp(8px, calc(var(--art-crop-h) * 0.18), 20px)";
@@ -185,7 +200,7 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr
an artless permanent loses its picture but never its game
state. `src` is non-null for face-down cards (CARD_BACK_URL),
so those still render the card back here. */}
- {src && !artError ? (
+ {renderCardBack || (src && !artError) ? (
` failed to load.
// Both render the card/token name (and Oracle text when known) so every artless
// card or token — not just one hard-coded name — stays identifiable.
- const renderedSrc = faceDown ? CARD_BACK_URL : (src ?? "");
+ // The card back is the fallback in BOTH directions: a marker that never
+ // resolves (`!src`) and a marker URL whose `
` fails to load
+ // (`imageError` — offline, CDN gap, stale printing) both fall back to it. A
+ // face-down permanent must never render a broken image, and it must never
+ // fall through to the artless text tile either: `showArtFallback` stays gated
+ // on `!faceDown`, so this is the only fallback the face-down path has.
+ const renderedSrc = faceDown
+ ? (imageError ? CARD_BACK_URL : (src ?? CARD_BACK_URL))
+ : (src ?? "");
const renderedAlt = faceDown ? t("card.faceDownName") : cardName;
return (
diff --git a/client/src/components/card/__tests__/ArtCropCard.test.tsx b/client/src/components/card/__tests__/ArtCropCard.test.tsx
index 14660a7b19..d28f31cd24 100644
--- a/client/src/components/card/__tests__/ArtCropCard.test.tsx
+++ b/client/src/components/card/__tests__/ArtCropCard.test.tsx
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { GameObject } from "../../../adapter/types.ts";
import { useCardImage } from "../../../hooks/useCardImage.ts";
+import { CARD_BACK_URL } from "../../../services/scryfall.ts";
import { useGameStore } from "../../../stores/gameStore.ts";
import { ArtCropCard } from "../ArtCropCard.tsx";
@@ -278,6 +279,43 @@ describe("ArtCropCard", () => {
expect(screen.getByAltText("Hidden Sorcery")).toBeInTheDocument();
});
+ it("falls back to the card back when face-down marker art fails to load", () => {
+ // ArtCropCard is the default battlefield renderer. Keep its marker failure
+ // path covered separately from CardImage: the component owns its own
+ // artError state and must never leave a face-down permanent as a broken
+ // image when a marker printing is unavailable.
+ mockUseCardImage.mockReturnValue({
+ src: "https://cards.scryfall.io/normal/front/m/a/manifest.jpg",
+ isLoading: false,
+ isRotated: false,
+ isFlip: false,
+ });
+ const permanent = {
+ ...transformedPermanent(),
+ face_down: true,
+ face_down_cause: "Manifest" as const,
+ transformed: false,
+ back_face: null,
+ color: [],
+ base_color: [],
+ };
+ useGameStore.setState({
+ gameState: { objects: { [permanent.id]: permanent } } as never,
+ });
+
+ render();
+
+ const marker = screen.getByAltText("Face-down card");
+ expect(marker).toHaveAttribute(
+ "src",
+ "https://cards.scryfall.io/normal/front/m/a/manifest.jpg",
+ );
+
+ fireEvent.error(marker);
+
+ expect(screen.getByAltText("Face-down card")).toHaveAttribute("src", CARD_BACK_URL);
+ });
+
it("keeps loyalty and P/T readable for planeswalkers and creature planeswalkers", () => {
mockUseCardImage.mockReturnValue({
src: "card.png",
diff --git a/client/src/components/card/__tests__/CardImage.test.tsx b/client/src/components/card/__tests__/CardImage.test.tsx
index 686649087c..53884a1ee2 100644
--- a/client/src/components/card/__tests__/CardImage.test.tsx
+++ b/client/src/components/card/__tests__/CardImage.test.tsx
@@ -227,3 +227,54 @@ describe("CardImage art fallback (issue #6156)", () => {
expect(img!.getAttribute("src")).toBe("https://example.invalid/back.png");
});
});
+
+describe("CardImage face-down marker (#7532)", () => {
+ it("renders the marker token art for a face-down permanent", () => {
+ mockUseCardImage.mockReturnValue({
+ src: "https://cards.scryfall.io/normal/front/m/a/manifest.jpg",
+ isLoading: false,
+ isRotated: false,
+ isFlip: false,
+ });
+
+ render();
+
+ const img = screen.getByRole("img");
+ expect(img).toHaveAttribute(
+ "src",
+ "https://cards.scryfall.io/normal/front/m/a/manifest.jpg",
+ );
+ });
+
+ it("falls back to the card back when the marker image fails to load", () => {
+ mockUseCardImage.mockReturnValue({
+ src: "https://cards.scryfall.io/normal/front/m/a/manifest.jpg",
+ isLoading: false,
+ isRotated: false,
+ isFlip: false,
+ });
+
+ render();
+ const img = screen.getByRole("img");
+ // A resolved marker URL can still 404 (CDN gap, stale printing). A face-down
+ // permanent must never show a broken image, and must not fall through to the
+ // artless text tile either — the card back is its only fallback.
+ fireEvent.error(img);
+
+ expect(screen.getByRole("img")).toHaveAttribute("src", CARD_BACK_URL);
+ });
+
+ it("keeps the card back when no marker applies", () => {
+ mockUseCardImage.mockReturnValue({
+ src: null,
+ isLoading: false,
+ isRotated: false,
+ isFlip: false,
+ });
+
+ // `TurnedFaceDown` (Ixidron) has no printed marker token.
+ render();
+
+ expect(screen.getByRole("img")).toHaveAttribute("src", CARD_BACK_URL);
+ });
+});
diff --git a/client/src/components/card/__tests__/faceDownMarker.test.ts b/client/src/components/card/__tests__/faceDownMarker.test.ts
new file mode 100644
index 0000000000..a88a9e3ef2
--- /dev/null
+++ b/client/src/components/card/__tests__/faceDownMarker.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+import { faceDownMarkerRef } from "../faceDownMarker.ts";
+
+describe("faceDownMarkerRef", () => {
+ it("maps each rules cause onto the printing paper play uses", () => {
+ expect(faceDownMarkerRef(true, "Manifest")?.face_name).toBe("manifest");
+ expect(faceDownMarkerRef(true, "Morph")?.face_name).toBe("morph");
+ // Cloak (CR 701.58a) and disguise (CR 702.168a) are different rules that
+ // share one printed token — the mapping is where they converge, not the
+ // engine's enum.
+ expect(faceDownMarkerRef(true, "Cloak")?.face_name).toBe("a mysterious creature");
+ expect(faceDownMarkerRef(true, "Disguise")?.face_name).toBe("a mysterious creature");
+ expect(faceDownMarkerRef(true, "Cloak")?.scryfall_oracle_id).toBe(
+ faceDownMarkerRef(true, "Disguise")?.scryfall_oracle_id,
+ );
+ });
+
+ it("has no marker for a cause with no printed token", () => {
+ // Ixidron turns permanents face down with no keyword action, and Wizards
+ // prints nothing for it — the generic card back stays.
+ expect(faceDownMarkerRef(true, "TurnedFaceDown")).toBeNull();
+ });
+
+ it("stays null unless the permanent is actually face down", () => {
+ // The engine leaves the cause on the object after it turns face up, so
+ // every reader must gate on `face_down`. This is that gate.
+ expect(faceDownMarkerRef(false, "Manifest")).toBeNull();
+ expect(faceDownMarkerRef(true, null)).toBeNull();
+ expect(faceDownMarkerRef(true, undefined)).toBeNull();
+ });
+});
diff --git a/client/src/components/card/faceDownMarker.ts b/client/src/components/card/faceDownMarker.ts
new file mode 100644
index 0000000000..3a793e703b
--- /dev/null
+++ b/client/src/components/card/faceDownMarker.ts
@@ -0,0 +1,61 @@
+import type { FaceDownCause, TokenImageRef } from "../../adapter/types.ts";
+
+/**
+ * The marker token Wizards prints for each face-down family.
+ *
+ * Paper play uses these as the required "what ability caused them to be face
+ * down" reminder (Duskmourn rulings, 2024-09-20), and the engine already tells
+ * us the cause. Mapping the cause onto a printing is a display decision, which
+ * is why the ids live here and not in the engine: four rules-level causes share
+ * three printed tokens, and one cause has no token at all.
+ *
+ * Oracle ids are used rather than a single printing's Scryfall id so the lookup
+ * survives a reprint — `fetchTokenImageByRef` falls back to the oracle key that
+ * `scryfall-token-images.json` already indexes for all three.
+ */
+const MARKERS: Partial> = {
+ // https://scryfall.com/card/tfrf/4/manifest — also used for manifest dread,
+ // which is the same keyword action with a different card-selection step.
+ Manifest: {
+ scryfall_id: "",
+ scryfall_oracle_id: "f4f184ef-f456-47d8-9012-095629a5ea4d",
+ face_name: "manifest",
+ preset_id: "face-down-manifest",
+ },
+ // https://scryfall.com/card/tdtk/7/morph — megamorph shares it.
+ Morph: {
+ scryfall_id: "",
+ scryfall_oracle_id: "8f92f8d7-ec89-426f-86dc-fbc259eb5559",
+ face_name: "morph",
+ preset_id: "face-down-morph",
+ },
+ // https://scryfall.com/card/tmkm/21/a-mysterious-creature — cloak and
+ // disguise are different rules (CR 701.58a vs CR 702.168a) with one printing.
+ Cloak: {
+ scryfall_id: "",
+ scryfall_oracle_id: "6481a124-6859-4f02-9fd3-b1302528dd2e",
+ face_name: "a mysterious creature",
+ preset_id: "face-down-cloak",
+ },
+ Disguise: {
+ scryfall_id: "",
+ scryfall_oracle_id: "6481a124-6859-4f02-9fd3-b1302528dd2e",
+ face_name: "a mysterious creature",
+ preset_id: "face-down-cloak",
+ },
+ // `TurnedFaceDown` (Ixidron class) is deliberately absent: no marker token is
+ // printed for it, so it keeps the generic card back.
+};
+
+/**
+ * The marker printing for a face-down permanent, or `null` when none applies —
+ * the permanent is face up, the engine did not record a cause (older saves), or
+ * the cause has no printed token.
+ */
+export function faceDownMarkerRef(
+ faceDown: boolean,
+ cause: FaceDownCause | null | undefined,
+): TokenImageRef | null {
+ if (!faceDown || !cause) return null;
+ return MARKERS[cause] ?? null;
+}
diff --git a/crates/engine/src/game/casting.rs b/crates/engine/src/game/casting.rs
index 0e32716b87..398854b59b 100644
--- a/crates/engine/src/game/casting.rs
+++ b/crates/engine/src/game/casting.rs
@@ -10340,10 +10340,15 @@ fn face_down_cast_profile(
state: &GameState,
object_id: ObjectId,
) -> crate::types::ability::FaceDownProfile {
+ // CR 702.168a / CR 702.37a: a face-down CAST reuses the manifest/cloak
+ // characteristics but is a different keyword action, so it restates the
+ // cause instead of leaving the constructor's default in place.
if super::keywords::object_has_effective_keyword_kind(state, object_id, KeywordKind::Disguise) {
crate::types::ability::FaceDownProfile::cloaked_2_2()
+ .caused_by(crate::types::ability::FaceDownCause::Disguise)
} else {
crate::types::ability::FaceDownProfile::vanilla_2_2()
+ .caused_by(crate::types::ability::FaceDownCause::Morph)
}
}
diff --git a/crates/engine/src/game/effects/change_zone.rs b/crates/engine/src/game/effects/change_zone.rs
index e142524dcb..043adc49a3 100644
--- a/crates/engine/src/game/effects/change_zone.rs
+++ b/crates/engine/src/game/effects/change_zone.rs
@@ -8049,6 +8049,7 @@ mod tests {
extra_core_types: vec![CoreType::Artifact],
subtypes: vec!["Cyberman".to_string()],
ward: None,
+ cause: crate::types::ability::FaceDownCause::Manifest,
}),
library_position: None,
random_order: false,
@@ -8738,6 +8739,7 @@ mod tests {
extra_core_types: vec![CoreType::Land],
subtypes: vec!["Forest".to_string()],
ward: None,
+ cause: crate::types::ability::FaceDownCause::Manifest,
};
let ability = ResolvedAbility::new(
diff --git a/crates/engine/src/game/effects/manifest.rs b/crates/engine/src/game/effects/manifest.rs
index 152bb5ac20..5be4e8fbee 100644
--- a/crates/engine/src/game/effects/manifest.rs
+++ b/crates/engine/src/game/effects/manifest.rs
@@ -411,6 +411,7 @@ mod tests {
extra_core_types: vec![CoreType::Artifact],
subtypes: vec!["Cyberman".to_string()],
ward: None,
+ cause: crate::types::ability::FaceDownCause::Manifest,
};
let ability = ResolvedAbility::new(
Effect::Manifest {
diff --git a/crates/engine/src/game/effects/turn_face_down.rs b/crates/engine/src/game/effects/turn_face_down.rs
index bcb62b2c47..b0f9535f26 100644
--- a/crates/engine/src/game/effects/turn_face_down.rs
+++ b/crates/engine/src/game/effects/turn_face_down.rs
@@ -1,4 +1,6 @@
-use crate::types::ability::{Effect, EffectError, EffectKind, FaceDownProfile, ResolvedAbility};
+use crate::types::ability::{
+ Effect, EffectError, EffectKind, FaceDownCause, FaceDownProfile, ResolvedAbility,
+};
use crate::types::events::GameEvent;
use crate::types::game_state::GameState;
@@ -24,7 +26,16 @@ pub fn resolve(
let (target, profile) = match &ability.effect {
Effect::TurnFaceDown { target, profile } => (
target.clone(),
- profile.clone().unwrap_or_else(FaceDownProfile::vanilla_2_2),
+ // CR 708.2: whatever characteristics the effect specifies, the
+ // ACTION here is a plain turn-face-down (Ixidron, Cyber Conversion)
+ // — no keyword action, and no marker token printed for it. The
+ // constructor default (manifest) and any authored profile both get
+ // the cause restated, so the profiled path cannot inherit a marker
+ // the rules never gave it.
+ profile
+ .clone()
+ .unwrap_or_else(FaceDownProfile::vanilla_2_2)
+ .caused_by(FaceDownCause::TurnedFaceDown),
),
_ => return Ok(()),
};
@@ -70,6 +81,11 @@ pub fn resolve(
// CR 708.2a + CR 205.1a: Apply the effect-specified (or default vanilla
// 2/2) face-down body.
crate::game::morph::apply_face_down_creature_characteristics(obj, &profile);
+ // The public record of what turned this permanent face down. The zone
+ // authority (`zone_pipeline::apply_face_down_entry_profile`) stamps the
+ // same field for an ENTERING face-down permanent; this resolver turns a
+ // permanent already on the battlefield, so it stamps its own.
+ obj.face_down_cause = Some(profile.cause);
obj.back_face = Some(snapshot);
changed = true;
events.push(GameEvent::TurnedFaceDown { object_id: id });
@@ -149,6 +165,7 @@ mod tests {
extra_core_types: vec![CoreType::Artifact],
subtypes: vec!["Cyberman".to_string()],
ward: None,
+ cause: crate::types::ability::FaceDownCause::TurnedFaceDown,
}
}
diff --git a/crates/engine/src/game/game_object.rs b/crates/engine/src/game/game_object.rs
index 68e0bdf38b..7e677046dd 100644
--- a/crates/engine/src/game/game_object.rs
+++ b/crates/engine/src/game/game_object.rs
@@ -430,6 +430,23 @@ pub struct GameObject {
// Battlefield state
pub tapped: bool,
pub face_down: bool,
+ /// Which keyword action put this permanent face down (CR 701.40a manifest,
+ /// CR 702.37a morph, CR 701.58a cloak, CR 702.168a disguise). `None` for a
+ /// face-up permanent.
+ ///
+ /// CR 708.2a makes every face-down permanent look alike, so this is not a
+ /// characteristic — it is the public record of how the permanent got here,
+ /// which the 2024-09-20 Duskmourn rulings require play to keep visible. No
+ /// game rule reads it; it exists so the display layer can show the marker
+ /// the physical game uses.
+ ///
+ /// Only meaningful while `face_down` is true. It is stamped on every
+ /// face-down entry and deliberately NOT cleared when the permanent turns
+ /// face up — a dozen unrelated paths clear `face_down`, and requiring each
+ /// to remember a second field is how a stale marker would eventually ship.
+ /// Read it gated on `face_down`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub face_down_cause: Option,
pub flipped: bool,
pub transformed: bool,
/// CR 701.27f: Number of successful transforms/conversions of this object.
@@ -1284,6 +1301,7 @@ fn _gameobject_partition_is_total(o: &GameObject) {
display_visible_to_viewer: _,
tapped: _,
face_down: _,
+ face_down_cause: _,
flipped: _,
transformed: _,
transformation_count: _,
@@ -2171,6 +2189,7 @@ impl GameObject {
display_visible_to_viewer: false,
tapped: false,
face_down: false,
+ face_down_cause: None,
flipped: false,
transformed: false,
transformation_count: 0,
diff --git a/crates/engine/src/game/morph.rs b/crates/engine/src/game/morph.rs
index e800c9c41e..dfa4f393ce 100644
--- a/crates/engine/src/game/morph.rs
+++ b/crates/engine/src/game/morph.rs
@@ -1495,6 +1495,7 @@ mod tests {
extra_core_types: vec![CoreType::Artifact],
subtypes: vec!["Cyberman".to_string()],
ward: None,
+ cause: crate::types::ability::FaceDownCause::Manifest,
};
{
let obj = state.objects.get_mut(&id).unwrap();
diff --git a/crates/engine/src/game/zone_pipeline.rs b/crates/engine/src/game/zone_pipeline.rs
index d08130e83d..05b4160e36 100644
--- a/crates/engine/src/game/zone_pipeline.rs
+++ b/crates/engine/src/game/zone_pipeline.rs
@@ -3186,6 +3186,11 @@ pub(crate) fn apply_face_down_entry_profile(
// survive the entry guard (which runs before exit cleanup); this is the
// authoritative final assertion that survives it.
obj.face_down = true;
+ // The public record of WHICH keyword action put this permanent face
+ // down. Re-stamped on every face-down entry, and only meaningful while
+ // `face_down` is true — the many turn-face-up paths leave it alone
+ // rather than each having to remember to clear it.
+ obj.face_down_cause = Some(profile.cause);
obj.back_face = Some(original);
}
}
diff --git a/crates/engine/src/parser/oracle_effect/sequence.rs b/crates/engine/src/parser/oracle_effect/sequence.rs
index 01d75c5ff6..f4256d1b0b 100644
--- a/crates/engine/src/parser/oracle_effect/sequence.rs
+++ b/crates/engine/src/parser/oracle_effect/sequence.rs
@@ -6129,6 +6129,7 @@ pub(super) fn parse_theyre_face_down_profile(lower: &str) -> Option Option Option,
+ /// Which keyword action is putting the permanent face down. Rides the
+ /// profile because the profile is what survives a CR 616.1 entry pause —
+ /// the parked `ZoneMoveRequest` carries it, and the resume path applies it
+ /// through the same helper.
+ #[serde(default)]
+ pub cause: FaceDownCause,
}
/// `serde` skip helper: the creature body is the CR 708.2a default and need not
@@ -11691,9 +11733,22 @@ impl FaceDownProfile {
extra_core_types: vec![],
subtypes: vec![],
ward: None,
+ cause: FaceDownCause::Manifest,
}
}
+ /// Restate the keyword action responsible for this face-down entry.
+ ///
+ /// The two constructors carry the characteristics-defining default
+ /// (manifest for the vanilla profile, cloak for the warded one). A caster
+ /// that reuses those characteristics for a DIFFERENT action — morph reuses
+ /// the vanilla profile, disguise the warded one — says so here rather than
+ /// letting the reader infer the action from the ward.
+ pub fn caused_by(mut self, cause: FaceDownCause) -> Self {
+ self.cause = cause;
+ self
+ }
+
/// CR 701.58a: The cloak face-down characteristics — a vanilla 2/2 creature
/// with ward {2}. Otherwise identical to [`Self::vanilla_2_2`]; the card can
/// still be turned face up for its mana cost if it's a creature card.
@@ -11702,6 +11757,7 @@ impl FaceDownProfile {
ward: Some(crate::types::keywords::WardCost::Mana(
crate::types::mana::ManaCost::generic(2),
)),
+ cause: FaceDownCause::Cloak,
..Self::vanilla_2_2()
}
}
diff --git a/crates/engine/src/types/game_state.rs b/crates/engine/src/types/game_state.rs
index 176f9e361f..e7f975f92b 100644
--- a/crates/engine/src/types/game_state.rs
+++ b/crates/engine/src/types/game_state.rs
@@ -30539,6 +30539,7 @@ mod tests {
extra_core_types: vec![crate::types::card_type::CoreType::Land],
subtypes: vec!["Forest".to_string()],
ward: None,
+ cause: crate::types::ability::FaceDownCause::Manifest,
}),
library_placement: None,
effect_kind: crate::types::ability::EffectKind::ChangeZone,
@@ -31020,6 +31021,7 @@ mod tests {
extra_core_types: vec![crate::types::card_type::CoreType::Land],
subtypes: vec!["Forest".to_string()],
ward: None,
+ cause: crate::types::ability::FaceDownCause::Manifest,
}),
enter_with_counters: vec![],
conditional_enter_with_counters: vec![],
diff --git a/crates/engine/tests/integration/face_down_cause_marker.rs b/crates/engine/tests/integration/face_down_cause_marker.rs
new file mode 100644
index 0000000000..610479bbb6
--- /dev/null
+++ b/crates/engine/tests/integration/face_down_cause_marker.rs
@@ -0,0 +1,216 @@
+//! CR 708.2 + the Duskmourn rulings of 2024-09-20: a face-down permanent records
+//! WHICH keyword action put it face down (#7532).
+//!
+//! > You must ensure that your face-down spells and permanents can be easily
+//! > differentiated from each other. … The order in which they entered should
+//! > remain clear, as well as what ability caused them to be face down. (This
+//! > includes manifest, disguise, cloak, morph, and a few older effects that
+//! > turn cards face down.)
+//!
+//! CR 708.2a gives every face-down permanent identical characteristics, so the
+//! object itself cannot answer that question and the display layer had nothing
+//! to show but a generic card back. No game rule reads the new field; it exists
+//! so the client can show the marker token paper play uses.
+//!
+//! The cause rides `FaceDownProfile`, which is what survives a CR 616.1 entry
+//! pause, and is stamped by the single face-down entry helper
+//! (`zone_pipeline::apply_face_down_entry_profile`).
+
+use engine::game::scenario::{GameScenario, P0};
+use engine::types::ability::FaceDownCause;
+use engine::types::game_state::WaitingFor;
+use engine::types::mana::ManaCost;
+use engine::types::phase::Phase;
+use engine::types::zones::Zone;
+
+/// Resolve `oracle` as a 0-cost sorcery with `library` cards available, then
+/// report the cause recorded on the one face-down permanent it produced.
+fn cause_after(
+ oracle: &str,
+ library: usize,
+ answer_manifest_choice: bool,
+) -> Option {
+ let mut scenario = GameScenario::new();
+ scenario.at_phase(Phase::PreCombatMain);
+ for i in 0..library {
+ scenario.add_card_to_library_top(P0, &format!("Library {i}"));
+ }
+ let spell = scenario
+ .add_spell_to_hand(P0, "Face-Down Maker", false)
+ .from_oracle_text(oracle)
+ .with_mana_cost(ManaCost::generic(0))
+ .id();
+ scenario.with_mana_pool(P0, vec![]);
+ let mut runner = scenario.build();
+
+ runner.cast(spell).resolve();
+ runner.advance_until_stack_empty();
+ if answer_manifest_choice {
+ if let WaitingFor::ManifestDreadChoice { cards, .. } = runner.state().waiting_for.clone() {
+ runner
+ .act(engine::types::actions::GameAction::SelectCards {
+ cards: vec![cards[0]],
+ })
+ .expect("choose the card to manifest");
+ runner.advance_until_stack_empty();
+ }
+ }
+
+ let face_down: Vec<_> = runner
+ .state()
+ .objects
+ .values()
+ .filter(|object| object.zone == Zone::Battlefield && object.face_down)
+ .collect();
+ assert_eq!(
+ face_down.len(),
+ 1,
+ "reach guard: exactly one face-down permanent, got {face_down:?}"
+ );
+ face_down[0].face_down_cause
+}
+
+/// CR 701.62a: manifest dread records the manifest cause.
+#[test]
+fn manifest_dread_records_the_manifest_cause() {
+ assert_eq!(
+ cause_after("Manifest dread.", 2, true),
+ Some(FaceDownCause::Manifest)
+ );
+}
+
+/// CR 701.40a: plain manifest records the same cause — same keyword action,
+/// different card-selection step, one marker token in paper.
+#[test]
+fn plain_manifest_records_the_manifest_cause() {
+ assert_eq!(
+ cause_after("Manifest the top card of your library.", 1, false),
+ Some(FaceDownCause::Manifest)
+ );
+}
+
+/// CR 701.58a: cloak is its own keyword action and gets its own marker, even
+/// though its characteristics are manifest's plus ward {2}. Keying the display
+/// on the ward instead would be classifying by shape rather than asking the
+/// rules.
+#[test]
+fn cloak_records_the_cloak_cause() {
+ assert_eq!(
+ cause_after("Cloak the top card of your library.", 1, false),
+ Some(FaceDownCause::Cloak)
+ );
+}
+
+/// The counter-direction that keeps the field honest: a face-UP permanent
+/// carries no cause, so a display layer that forgets to gate on `face_down`
+/// still has nothing to show.
+#[test]
+fn a_face_up_permanent_records_no_cause() {
+ let mut scenario = GameScenario::new();
+ let creature = scenario.add_creature(P0, "Plain Creature", 2, 2).id();
+ let runner = scenario.build();
+ assert!(!runner.state().objects[&creature].face_down);
+ assert_eq!(runner.state().objects[&creature].face_down_cause, None);
+}
+
+/// CR 702.37a: a face-down CAST is morph, not manifest, even though it reuses
+/// the same vanilla 2/2 characteristics. Keying the marker on those
+/// characteristics would show the wrong token.
+#[test]
+fn a_morph_cast_records_the_morph_cause() {
+ assert_eq!(
+ cause_after_face_down_cast(engine::types::keywords::Keyword::Morph(
+ engine::types::mana::ManaCost::generic(5)
+ )),
+ Some(FaceDownCause::Morph)
+ );
+}
+
+/// CR 702.168a: disguise reuses cloak's warded characteristics and is still its
+/// own action — the ward is shared, the marker source is not.
+#[test]
+fn a_disguise_cast_records_the_disguise_cause() {
+ assert_eq!(
+ cause_after_face_down_cast(engine::types::keywords::Keyword::Disguise(
+ engine::types::keywords::DisguiseCost::Mana(engine::types::mana::ManaCost::generic(5))
+ )),
+ Some(FaceDownCause::Disguise)
+ );
+}
+
+/// Cast a creature carrying `keyword` face down for its fixed {3} and report the
+/// cause recorded on the resulting permanent.
+fn cause_after_face_down_cast(keyword: engine::types::keywords::Keyword) -> Option {
+ use engine::types::mana::{ManaType, ManaUnit};
+ let mut scenario = GameScenario::new();
+ scenario.at_phase(Phase::PreCombatMain);
+ let card = scenario
+ .add_creature_to_hand(P0, "Face-Down Cast Probe", 4, 4)
+ .with_mana_cost(ManaCost::NoCost)
+ .with_keyword(keyword)
+ .id();
+ scenario.with_mana_pool(
+ P0,
+ (0..3)
+ .map(|_| ManaUnit::new(ManaType::Colorless, card, false, vec![]))
+ .collect(),
+ );
+ let mut runner = scenario.build();
+ runner.cast(card).commit().resolve();
+ runner.advance_until_stack_empty();
+
+ let object = &runner.state().objects[&card];
+ assert!(
+ object.face_down && object.zone == Zone::Battlefield,
+ "reach guard: the card must have entered the battlefield face down, got {object:?}"
+ );
+ object.face_down_cause
+}
+
+/// CR 708.2: an effect that turns a permanent already on the battlefield face
+/// down is no keyword action at all — Wizards prints no marker for it, so the
+/// cause must say so instead of inheriting the manifest default the vanilla
+/// profile carries.
+#[test]
+fn a_generic_turn_face_down_records_its_own_cause() {
+ assert_eq!(
+ cause_after_turn_face_down("Turn target creature face down."),
+ Some(FaceDownCause::TurnedFaceDown)
+ );
+}
+
+/// The PROFILED variant (Cyber Conversion) takes the same path with an authored
+/// body, and must not pick up a different cause because its profile is authored
+/// rather than defaulted.
+#[test]
+fn a_profiled_turn_face_down_records_its_own_cause() {
+ assert_eq!(
+ cause_after_turn_face_down(
+ "Turn target creature face down. It's a 2/2 Cyberman artifact creature."
+ ),
+ Some(FaceDownCause::TurnedFaceDown)
+ );
+}
+
+fn cause_after_turn_face_down(oracle: &str) -> Option {
+ let mut scenario = GameScenario::new();
+ scenario.at_phase(Phase::PreCombatMain);
+ let victim = scenario.add_creature(P0, "Victim", 3, 3).id();
+ let spell = scenario
+ .add_spell_to_hand(P0, "Turn-Down Probe", false)
+ .from_oracle_text(oracle)
+ .with_mana_cost(ManaCost::generic(0))
+ .id();
+ scenario.with_mana_pool(P0, vec![]);
+ let mut runner = scenario.build();
+
+ runner.cast(spell).target_object(victim).resolve();
+ runner.advance_until_stack_empty();
+
+ let object = &runner.state().objects[&victim];
+ assert!(
+ object.face_down,
+ "reach guard: the creature must have been turned face down, got {object:?}"
+ );
+ object.face_down_cause
+}
diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs
index e1744a5dc5..b522435a4c 100644
--- a/crates/engine/tests/integration/main.rs
+++ b/crates/engine/tests/integration/main.rs
@@ -231,6 +231,7 @@ mod exquisite_blood_routing;
mod extract_power_each_player_exile;
mod exuberant_wolfbear_base_pt_target;
mod eyetwitch_learn_decline_lesson;
+mod face_down_cause_marker;
mod fact_or_fiction_pile_separation;
mod fantastic_four_bounded_loop;
mod fateful_handoff_target_mana_value_draw;