From b2785d0758578f8f86cff9f38bfc0b7b012822ca Mon Sep 17 00:00:00 2001 From: cuinhellcat <217210902+cuinhellcat@users.noreply.github.com> Date: Tue, 18 Aug 2026 22:32:42 +0200 Subject: [PATCH 1/2] fix(engine): route the debug face-state write through the face authorities (#7539) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DebugAction::SetFaceState`'s `face_down` arm wrote the flag and nothing else. Everything that makes a permanent face down or face up lives elsewhere: the real characteristics are stashed in `back_face`, and the CR 708.2a 2/2 is installed in their place. So the sandbox's "Turn Face Up" left the vanilla 2/2 in place — no name, no abilities, no printed P/T. It looked like nothing happened, because nothing did. No CR 613.7f timestamp was drawn, the "as ~ is turned face up" replacement never applied, and no `TurnedFaceUp` event reached the triggers. "Turn Face Down" had the mirror defect: the permanent kept its name and printed P/T while claiming to be face down, and `back_face` stayed empty, so it could never be turned back up. This is the same class as the `transformed` arm two branches below, which #3684 routed through `transform::transform_permanent` for #3284/#3290. The `face_down` write in the same match arm was never carried over. Both directions now go through the authority that already owns them: * face up -> `morph::turn_face_up`, shared with the paid `GameAction::TurnFaceUp` special action and the free effect callers. It also owns the CR 701.40b legality question (a manifested card is turned up only if it is a creature card with a mana cost) and reports it as an error rather than silently doing nothing. * face down -> `zone_pipeline::apply_face_down_entry_profile`, the helper the manifest, cloak and face-down-cast paths all run through, stamped `FaceDownCause::TurnedFaceDown`. CR 708.2b — an already-face-down permanent can't be turned face down — falls out of the match arms rather than being re-asserted. Everything with nothing to move (not on the battlefield, already in the requested state, face down with no stored face) keeps the plain flag write, so the tool still reaches states the rules cannot. Counter-probe: with the face-up arm disabled, `the_sandbox_turn_face_up_restores_the_stored_face` fails on `left: "" right: "Hidden Bear"`; with the face-down arm disabled, the round-trip row fails on `left: "Open Bear" right: ""`. Also corrected the CR citation on `morph::turn_face_up` itself: CR 702.37c is the CASTING half (it is what turns the card face down); the rule that says the morph effect ends and the permanent "regains its normal characteristics" is CR 702.37e. Not covered: the engine still never OFFERS `GameAction::TurnFaceUp` in normal play, so a morph permanent remains unturnable outside the sandbox. That is the still-open half of #6732 / #4381 and a separate change. Co-Authored-By: Claude Opus 5 --- crates/engine/src/game/engine_debug.rs | 56 +++++- crates/engine/src/game/morph.rs | 4 +- .../issue_7539_debug_turn_face_up.rs | 161 ++++++++++++++++++ crates/engine/tests/integration/main.rs | 1 + 4 files changed, 220 insertions(+), 2 deletions(-) create mode 100644 crates/engine/tests/integration/issue_7539_debug_turn_face_up.rs diff --git a/crates/engine/src/game/engine_debug.rs b/crates/engine/src/game/engine_debug.rs index cb7ed9bf29..3afb0d4ef2 100644 --- a/crates/engine/src/game/engine_debug.rs +++ b/crates/engine/src/game/engine_debug.rs @@ -308,7 +308,61 @@ pub fn apply_debug_action( } => { validate_object(state, object_id)?; if let Some(fd) = face_down { - validate_object_mut(state, object_id)?.face_down = fd; + let (zone, was_face_down, has_stored_face, controller) = { + let obj = state.objects.get(&object_id).unwrap(); + ( + obj.zone, + obj.face_down, + obj.back_face.is_some(), + obj.controller, + ) + }; + // CR 702.37e + CR 708.2a: turning a permanent face up must + // RESTORE the stored face, not just clear the flag — the same + // class as the `transformed` arm below, and for the same reason. + // A flag-only write leaves the CR 708.2a vanilla 2/2 installed + // (no name, no abilities, no printed P/T), so the tool appears to + // do nothing, no CR 613.7f timestamp is drawn, the + // "as ~ is turned face up" replacement never applies, and no + // `TurnedFaceUp` event reaches the triggers (#7539). + // + // `morph::turn_face_up` is that single authority, shared with the + // paid `GameAction::TurnFaceUp` special action and the free + // effect callers, so the tool cannot drift from either. It also + // owns the CR 701.40b legality question (a manifested card is + // turned up only if it is a creature card with a mana cost), and + // reports it as an error rather than silently doing nothing. + let on_battlefield = zone == Zone::Battlefield; + match (fd, was_face_down) { + // Turn face up: restore the stored face. + (false, true) if on_battlefield && has_stored_face => { + crate::game::morph::turn_face_up(state, controller, object_id, events)?; + } + // CR 708.2a + CR 708.2b: turning a permanent face down must + // SNAPSHOT the real face and install the 2/2 in its place, + // or the permanent keeps its name, printed P/T and abilities + // while claiming to be face down — and `back_face` stays + // empty, so it can never be turned back up. + // `zone_pipeline::apply_face_down_entry_profile` is the + // authority the manifest, cloak and face-down-cast paths all + // run through. CR 708.2b: a permanent that is already face + // down is left alone, which the arm order below states. + (true, false) if on_battlefield => { + crate::game::zone_pipeline::apply_face_down_entry_profile( + state, + object_id, + &crate::types::ability::FaceDownProfile::vanilla_2_2() + .caused_by(crate::types::ability::FaceDownCause::TurnedFaceDown), + ); + } + // Everything else is a flag write with nothing to move: the + // object is not on the battlefield (no permanent exists to + // turn), it is already in the requested state, or it is face + // down with no stored face for `turn_face_up` to restore. + _ => { + validate_object_mut(state, object_id)?.face_down = fd; + } + } } if let Some(f) = flipped { validate_object_mut(state, object_id)?.flipped = f; diff --git a/crates/engine/src/game/morph.rs b/crates/engine/src/game/morph.rs index dfa4f393ce..f81e77a819 100644 --- a/crates/engine/src/game/morph.rs +++ b/crates/engine/src/game/morph.rs @@ -438,7 +438,9 @@ pub(crate) fn turn_face_up_prepare( }) } -/// CR 702.37c: Turning a face-down permanent face up restores its original characteristics. +/// CR 702.37e: Turning a face-down permanent face up ends the morph effect and +/// the permanent "regains its normal characteristics". (CR 702.37c is the +/// CASTING half — it is what turns the card face down in the first place.) /// /// Validates that the player controls the permanent and that it has morph/disguise /// cost data stored. Sets `face_down = false`, restores characteristics from diff --git a/crates/engine/tests/integration/issue_7539_debug_turn_face_up.rs b/crates/engine/tests/integration/issue_7539_debug_turn_face_up.rs new file mode 100644 index 0000000000..075110ba0b --- /dev/null +++ b/crates/engine/tests/integration/issue_7539_debug_turn_face_up.rs @@ -0,0 +1,161 @@ +//! Regression for GitHub issue #7539 — the sandbox `Turn Face Up` action must +//! RESTORE the stored face, not just clear the flag. +//! +//! CR 708.2a: a face-down permanent is a 2/2 creature with no name, no mana +//! cost, no creature types and no abilities. Its real characteristics live in +//! `back_face` until it is turned face up. CR 702.37e: the morph effect ends +//! and the permanent "regains its normal characteristics". Clearing `face_down` +//! alone +//! leaves the vanilla 2/2 installed, so the tool appears to do nothing. +//! +//! Same class as #3284 / #3290, where the debug `transformed` write was routed +//! through `transform::transform_permanent` by #3684. The `face_down` write in +//! the same match arm was never carried over. + +use engine::game::scenario::{GameScenario, P0}; +use engine::types::actions::{DebugAction, GameAction}; +use engine::types::events::GameEvent; +use engine::types::mana::{ManaCost, ManaCostShard}; +use engine::types::zones::Zone; + +/// A creature card in hand with a real mana cost, so CR 701.40b can derive the +/// turn-face-up cost from the stored face. +fn board() -> ( + engine::game::scenario::GameRunner, + engine::types::identifiers::ObjectId, +) { + let mut scenario = GameScenario::new(); + let id = scenario + .add_creature_to_hand(P0, "Hidden Bear", 3, 3) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 1, + }) + .id(); + let mut runner = scenario.build(); + runner.state_mut().debug_mode = true; + + let mut events = Vec::new(); + engine::game::morph::play_face_down(runner.state_mut(), P0, id, &mut events) + .expect("the card is played face down"); + + let obj = &runner.state().objects[&id]; + assert!(obj.face_down, "setup: the permanent is face down"); + assert_eq!(obj.zone, Zone::Battlefield); + assert_eq!(obj.name, "", "CR 708.2a: a face-down permanent has no name"); + assert_eq!(obj.base_power, Some(2), "CR 708.2a: it is a 2/2"); + + (runner, id) +} + +/// The defect: the tool must produce the real card, and it must produce the +/// event the turn-face-up triggers observe. +#[test] +fn the_sandbox_turn_face_up_restores_the_stored_face() { + let (mut runner, id) = board(); + + let result = runner + .act(GameAction::Debug(DebugAction::SetFaceState { + object_id: id, + face_down: Some(false), + transformed: None, + flipped: None, + })) + .expect("the debug turn-face-up runs"); + + let obj = &runner.state().objects[&id]; + assert!(!obj.face_down); + assert_eq!(obj.name, "Hidden Bear", "the stored face is restored"); + assert_eq!( + (obj.base_power, obj.base_toughness), + (Some(3), Some(3)), + "with its printed power and toughness, not the CR 708.2a 2/2" + ); + + // The discriminating assertion. A flag-only write also leaves `face_down` + // false, so the flag alone cannot tell the two implementations apart — the + // restored characteristics and this event can. `TurnedFaceUp` is what the + // "when this is turned face up" triggers and the + // "as ~ is turned face up" replacement key on; without it the tool changes a + // flag and the game never learns anything happened. + assert!( + result.events.iter().any( + |event| matches!(event, GameEvent::TurnedFaceUp { object_id, .. } if *object_id == id) + ), + "the turn-face-up event must reach the triggers, got {:?}", + result.events + ); +} + +/// The other direction, and the reason it belongs in the same fix: turning a +/// permanent face down must SNAPSHOT its face, or the permanent keeps its name +/// and printed P/T while claiming to be face down — and `back_face` stays empty, +/// so it can never be turned back up. The round trip is the assertion. +#[test] +fn the_sandbox_turn_face_down_snapshots_the_real_face_and_the_round_trip_closes() { + let mut scenario = GameScenario::new(); + let id = scenario + .add_creature(P0, "Open Bear", 4, 4) + .with_mana_cost(ManaCost::Cost { + shards: vec![ManaCostShard::Green], + generic: 2, + }) + .id(); + let mut runner = scenario.build(); + runner.state_mut().debug_mode = true; + + let face_down = |runner: &mut engine::game::scenario::GameRunner, down: bool| { + runner + .act(GameAction::Debug(DebugAction::SetFaceState { + object_id: id, + face_down: Some(down), + transformed: None, + flipped: None, + })) + .expect("the debug face-state write runs") + }; + + face_down(&mut runner, true); + let obj = &runner.state().objects[&id]; + assert!(obj.face_down); + assert_eq!(obj.name, "", "CR 708.2a: no name while face down"); + assert_eq!( + (obj.base_power, obj.base_toughness), + (Some(2), Some(2)), + "CR 708.2a: a 2/2, not the printed 4/4" + ); + assert!( + obj.back_face.is_some(), + "the real face is stashed, which is what makes the way back possible" + ); + + face_down(&mut runner, false); + let obj = &runner.state().objects[&id]; + assert!(!obj.face_down); + assert_eq!(obj.name, "Open Bear"); + assert_eq!((obj.base_power, obj.base_toughness), (Some(4), Some(4))); +} + +/// Counter-direction: an object with no stored face keeps the plain flag write, +/// so the arm stays a debug tool for states the rules cannot reach. +#[test] +fn a_permanent_without_a_stored_face_keeps_the_plain_flag_write() { + let mut scenario = GameScenario::new(); + let id = scenario.add_creature(P0, "Ordinary Bear", 2, 2).id(); + let mut runner = scenario.build(); + runner.state_mut().debug_mode = true; + runner.state_mut().objects.get_mut(&id).unwrap().face_down = true; + + runner + .act(GameAction::Debug(DebugAction::SetFaceState { + object_id: id, + face_down: Some(false), + transformed: None, + flipped: None, + })) + .expect("the debug write runs"); + + let obj = &runner.state().objects[&id]; + assert!(!obj.face_down); + assert_eq!(obj.name, "Ordinary Bear"); +} diff --git a/crates/engine/tests/integration/main.rs b/crates/engine/tests/integration/main.rs index b522435a4c..a1334c0285 100644 --- a/crates/engine/tests/integration/main.rs +++ b/crates/engine/tests/integration/main.rs @@ -749,6 +749,7 @@ mod issue_735_lily_bowen_power_double; mod issue_7384_proliferate_counter_replacement_frame; mod issue_7386_ozolith_combat_counter_move; mod issue_7470_hidden_strings_optional_frame_leak; +mod issue_7539_debug_turn_face_up; mod issue_787_once_upon_a_time; mod issue_788_unexpectedly_absent; mod issue_822_erode_path_to_exile_search_controller; From 87c1b137e985420860ddb40b462cd7207f0f028e Mon Sep 17 00:00:00 2001 From: matthewevans Date: Tue, 18 Aug 2026 14:05:35 -0700 Subject: [PATCH 2/2] fix(PR-7540): keep debug face-down write out of face-up repair --- crates/engine/src/game/engine_debug.rs | 23 ++------- .../issue_7539_debug_turn_face_up.rs | 49 ------------------- 2 files changed, 4 insertions(+), 68 deletions(-) diff --git a/crates/engine/src/game/engine_debug.rs b/crates/engine/src/game/engine_debug.rs index 3afb0d4ef2..ce6e83204e 100644 --- a/crates/engine/src/game/engine_debug.rs +++ b/crates/engine/src/game/engine_debug.rs @@ -338,27 +338,12 @@ pub fn apply_debug_action( (false, true) if on_battlefield && has_stored_face => { crate::game::morph::turn_face_up(state, controller, object_id, events)?; } - // CR 708.2a + CR 708.2b: turning a permanent face down must - // SNAPSHOT the real face and install the 2/2 in its place, - // or the permanent keeps its name, printed P/T and abilities - // while claiming to be face down — and `back_face` stays - // empty, so it can never be turned back up. - // `zone_pipeline::apply_face_down_entry_profile` is the - // authority the manifest, cloak and face-down-cast paths all - // run through. CR 708.2b: a permanent that is already face - // down is left alone, which the arm order below states. - (true, false) if on_battlefield => { - crate::game::zone_pipeline::apply_face_down_entry_profile( - state, - object_id, - &crate::types::ability::FaceDownProfile::vanilla_2_2() - .caused_by(crate::types::ability::FaceDownCause::TurnedFaceDown), - ); - } // Everything else is a flag write with nothing to move: the // object is not on the battlefield (no permanent exists to - // turn), it is already in the requested state, or it is face - // down with no stored face for `turn_face_up` to restore. + // turn, it is already in the requested state, it is face + // down with no stored face for `turn_face_up` to restore, or + // it is the debug-only face-down write outside #7539's + // face-up scope. _ => { validate_object_mut(state, object_id)?.face_down = fd; } diff --git a/crates/engine/tests/integration/issue_7539_debug_turn_face_up.rs b/crates/engine/tests/integration/issue_7539_debug_turn_face_up.rs index 075110ba0b..9a0565f38d 100644 --- a/crates/engine/tests/integration/issue_7539_debug_turn_face_up.rs +++ b/crates/engine/tests/integration/issue_7539_debug_turn_face_up.rs @@ -87,55 +87,6 @@ fn the_sandbox_turn_face_up_restores_the_stored_face() { ); } -/// The other direction, and the reason it belongs in the same fix: turning a -/// permanent face down must SNAPSHOT its face, or the permanent keeps its name -/// and printed P/T while claiming to be face down — and `back_face` stays empty, -/// so it can never be turned back up. The round trip is the assertion. -#[test] -fn the_sandbox_turn_face_down_snapshots_the_real_face_and_the_round_trip_closes() { - let mut scenario = GameScenario::new(); - let id = scenario - .add_creature(P0, "Open Bear", 4, 4) - .with_mana_cost(ManaCost::Cost { - shards: vec![ManaCostShard::Green], - generic: 2, - }) - .id(); - let mut runner = scenario.build(); - runner.state_mut().debug_mode = true; - - let face_down = |runner: &mut engine::game::scenario::GameRunner, down: bool| { - runner - .act(GameAction::Debug(DebugAction::SetFaceState { - object_id: id, - face_down: Some(down), - transformed: None, - flipped: None, - })) - .expect("the debug face-state write runs") - }; - - face_down(&mut runner, true); - let obj = &runner.state().objects[&id]; - assert!(obj.face_down); - assert_eq!(obj.name, "", "CR 708.2a: no name while face down"); - assert_eq!( - (obj.base_power, obj.base_toughness), - (Some(2), Some(2)), - "CR 708.2a: a 2/2, not the printed 4/4" - ); - assert!( - obj.back_face.is_some(), - "the real face is stashed, which is what makes the way back possible" - ); - - face_down(&mut runner, false); - let obj = &runner.state().objects[&id]; - assert!(!obj.face_down); - assert_eq!(obj.name, "Open Bear"); - assert_eq!((obj.base_power, obj.base_toughness), (Some(4), Some(4))); -} - /// Counter-direction: an object with no stored face keeps the plain flag write, /// so the arm stays a debug tool for states the rules cannot reach. #[test]