Skip to content
13 changes: 12 additions & 1 deletion crates/engine/src/game/ability_rw.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3911,7 +3911,18 @@ fn walk_ability(
target_choice_timing: _,
description: _,
selected_mode_labels: _, // display snapshots, no game-state read/write
min_x_value: _, // u32, no read
// CR 700.2: mode-root position marker β€” reads and writes NOTHING on any
// of the profiler's axes (kind+scope, `reads_member_bound`,
// `reads_event_live`, `writes_event_object`). It gates when the chain's
// tracked-set identity RESETS, which narrows what a later member-bound
// read can see; narrowing never adds a read, and the member-bound axis is
// already set by the `TrackedSet`-bearing effects themselves.
modal_instruction_ordinal: _,
// CR 608.2c: structural record of what a chain SPLIT detached. Read-FREE:
// it selects nothing from game state and gates only whether a producer
// may publish its population, which can narrow but never widen.
detached_remainder: _,
min_x_value: _, // u32, no read
cant_be_copied: _,
copy_count_status: _,
forward_result: _,
Expand Down
64 changes: 41 additions & 23 deletions crates/engine/src/game/ability_scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,28 +243,37 @@ fn resolved_ability_axes(a: &ResolvedAbility, mode: ScanMode) -> Axes {
optional_targeting: _, // bool
optional: _, // bool
optional_player,
optional_for: _, // OpponentMayScope: AnyOpponent/AnyPlayer, no read
target_choice_timing: _, // Stack/Resolution tag
description: _, // display string
selected_mode_labels: _, // display strings, no dynamic read
min_x_value: _, // u32
cant_be_copied: _, // bool
copy_count_status: _, // status tag
forward_result: _, // bool
distribution: _, // concrete pre-assigned (TargetRef, u32) portions
chosen_x: _, // concrete cast-time X
cost_paid_object: _, // concrete captured-object snapshot
cost_paid_object_ids: _, // concrete captured-object ids (issue #4948)
effect_context_object: _, // concrete captured-object snapshot
amassed_army_object: _, // concrete captured-object snapshot
ability_index: _, // usize provenance
may_trigger_origin: _, // provenance tag
target_selection_mode: _, // Chosen/Random tag
chosen_players: _, // concrete chosen player ids
replacement_applied: _, // replacement provenance set, no dynamic read
sub_link: _, // SubAbilityLink kind tag
sibling_condition: _, // SiblingCondition replication marker, no dynamic read
distribute: _, // announcement unit tag/string, no resolution-time dynamic read
optional_for: _, // OpponentMayScope: AnyOpponent/AnyPlayer, no read
target_choice_timing: _, // Stack/Resolution tag
description: _, // display string
selected_mode_labels: _, // display strings, no dynamic read
// CR 700.2: mode-root position marker. Read-FREE on every scan axis: it
// selects nothing from game state, it only says "a new instruction starts
// here". The instructions themselves are `effect`/`sub_ability`, already
// scanned above, so the axes of a chain are identical with or without it.
modal_instruction_ordinal: _,
// CR 608.2c: structural record of what a chain SPLIT detached. Read-FREE:
// it selects nothing from game state and gates only whether a producer
// may publish its population, which can narrow but never widen.
detached_remainder: _,
min_x_value: _, // u32
cant_be_copied: _, // bool
copy_count_status: _, // status tag
forward_result: _, // bool
distribution: _, // concrete pre-assigned (TargetRef, u32) portions
chosen_x: _, // concrete cast-time X
cost_paid_object: _, // concrete captured-object snapshot
cost_paid_object_ids: _, // concrete captured-object ids (issue #4948)
effect_context_object: _, // concrete captured-object snapshot
amassed_army_object: _, // concrete captured-object snapshot
ability_index: _, // usize provenance
may_trigger_origin: _, // provenance tag
target_selection_mode: _, // Chosen/Random tag
chosen_players: _, // concrete chosen player ids
replacement_applied: _, // replacement provenance set, no dynamic read
sub_link: _, // SubAbilityLink kind tag
sibling_condition: _, // SiblingCondition replication marker, no dynamic read
distribute: _, // announcement unit tag/string, no resolution-time dynamic read
parent_target_missing_reason: _, // seam flag
} = a;

Expand Down Expand Up @@ -7259,7 +7268,7 @@ mod tests {
/// helper-enumerator mass reads on existing relaxed variants; raw-iteration mass
/// reads rely on the oracle's no-wildcard forcing.
/// - BOUNDED raw-iter / O(1) reads are deliberately kept OUT of `CLASSIFIED` (so the
/// set-equality stays over the 14 idiom-matched files β€” no allowlist pollution):
/// set-equality stays over the 15 idiom-matched files β€” no allowlist pollution):
/// `vote.rs` (`votes_per_session_for` = 1 + count of `GrantsExtraVote` statics,
/// snapshotted at session start β€” bounded single outcome) and `switch_pt.rs`
/// (O(1) `state.battlefield.contains()` over the effect's own `ids` β€” bounded
Expand Down Expand Up @@ -7323,6 +7332,14 @@ mod tests {
"PhaseOut/PhaseIn: targets-empty -> battlefield_phased_in_ids / \
state.battlefield mass scan (CR 702.26)",
),
(
"pump.rs",
true,
"PumpAll (pump_all_affected_objects): battlefield_phased_in_ids mass pump, \
a read that scales with the board; single Pump path also present in-file. \
Joined the idiom in #7484, when the producer moved off a raw \
state.battlefield scan onto the same enumeration goad.rs uses",
),
(
"turn_face_up.rs",
true,
Expand Down Expand Up @@ -7421,6 +7438,7 @@ mod tests {
("counters.rs", "PutCounterAll"),
("goad.rs", "GoadAll"),
("phase_out.rs", "PhaseOut"),
("pump.rs", "PumpAll"),
("turn_face_up.rs", "TurnFaceUp"),
("turn_face_down.rs", "TurnFaceDown"),
];
Expand Down
210 changes: 207 additions & 3 deletions crates/engine/src/game/ability_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,8 +303,12 @@ pub fn build_chained_resolved(
controller: PlayerId,
) -> Result<ResolvedAbility, EngineError> {
if indices.is_empty() {
// CR 700.2a: "Choose up to one" permits choosing no modes. The ability
// still resolves, but it has no instructions to perform.
// CR 700.2: the modes are the bulleted options, chosen per "instructions
// for a player to choose A NUMBER of those options" β€” and under "choose up
// to one" that number may be zero. The ability still resolves; it just has
// no instructions to perform. (Not CR 700.2a, which is about WHEN modes are
// chosen and illegal modes; not CR 700.2i, whose "choose up to" is specific
// to pawprint {P} worth of modes.)
return Ok(ResolvedAbility::new(
Effect::GenericEffect {
static_abilities: Vec::new(),
Expand All @@ -321,11 +325,19 @@ pub fn build_chained_resolved(
let ordered = ordered_selected_mode_indices(indices);

let mut result: Option<ResolvedAbility> = None;
for &idx in ordered.iter().rev() {
for (ordinal, &idx) in ordered.iter().enumerate().rev() {
let def = abilities
.get(idx)
.ok_or_else(|| EngineError::InvalidAction(format!("Mode index {idx} out of range")))?;
let mut resolved = build_resolved_from_def(def, source_id, controller);
// CR 700.2 ("each of those options is a mode") + CR 700.2d: stamp this
// mode root with its OCCURRENCE ORDINAL within the ordered selection β€”
// taken from `enumerate()`, never from `idx`. `ordered_selected_mode_indices`
// preserves duplicates, so an `allow_repeat_modes` card (Eldrazi
// Confluence, `[1, 1]`) has two distinct instructions at one printed
// index; keying on `idx` would collapse them into one. This is the ONLY
// write site for the field (see its doc on `ResolvedAbility`).
resolved.modal_instruction_ordinal = Some(ordinal);
// CR 700.2d: When chaining multiple modes, append subsequent modes after
// the current mode's own sub_ability chain (e.g., Cathartic Pyre mode 2's
// "discard, then draw that many" must preserve the draw sub_ability).
Expand Down Expand Up @@ -9604,6 +9616,198 @@ mod tests {
);
}

/// CR 700.2d: the mode-root stamp is the OCCURRENCE ORDINAL, not the printed
/// mode index. "If a particular mode is chosen multiple times, the spell is
/// treated as if that mode appeared that many times in sequence" β€” so a
/// repeated mode is two independent instructions and must carry two distinct
/// ordinals even though both live at the same printed index.
///
/// DISCRIMINATION: key the stamp on `idx` instead of `enumerate()`'s counter
/// and the `[1, 1]` arm reads `Some(1), Some(1)` β€” the two occurrences
/// collapse into one instruction, which is exactly what a mode-boundary
/// consumer must not see. The `[0, 1, 2]` arm cannot distinguish the two
/// keyings (index == ordinal there), which is why the repeat arm is here.
#[test]
fn build_chained_resolved_stamps_occurrence_ordinals_not_printed_indices() {
let mode = |effect| AbilityDefinition::new(AbilityKind::Spell, effect);
let abilities = vec![
mode(Effect::Destroy {
target: TargetFilter::Any,
cant_regenerate: false,
}),
mode(Effect::Draw {
count: QuantityExpr::Fixed { value: 1 },
target: TargetFilter::Controller,
}),
mode(Effect::GainLife {
amount: QuantityExpr::Fixed { value: 1 },
player: TargetFilter::Controller,
}),
];

let distinct =
build_chained_resolved(&abilities, &[0, 1, 2], ObjectId(1), PlayerId(0)).unwrap();
let second = distinct.sub_ability.as_deref().expect("mode 1 follows");
let third = second.sub_ability.as_deref().expect("mode 2 follows");
assert_eq!(
(
distinct.modal_instruction_ordinal,
second.modal_instruction_ordinal,
third.modal_instruction_ordinal,
),
(Some(0), Some(1), Some(2)),
"CR 700.2: every mode root is stamped, including the first"
);

// CR 700.2d: Eldrazi Confluence's `allow_repeat_modes` shape.
let repeated =
build_chained_resolved(&abilities, &[1, 1], ObjectId(1), PlayerId(0)).unwrap();
let repeated_second = repeated
.sub_ability
.as_deref()
.expect("the repeated mode occurs twice in sequence");
assert!(
matches!(repeated.effect, Effect::Draw { .. })
&& matches!(repeated_second.effect, Effect::Draw { .. }),
"reach-guard: both occurrences must really be printed mode 1, or the \
distinct-ordinal assertion below is about the wrong nodes"
);
assert_eq!(
(
repeated.modal_instruction_ordinal,
repeated_second.modal_instruction_ordinal,
),
(Some(0), Some(1)),
"CR 700.2d: two occurrences of ONE printed mode are two instructions. \
Keying on the printed index would give (Some(1), Some(1))"
);

// CR 700.2: the modes are the bulleted options, so "choose up to one"
// with zero chosen has no instructions at all β€” it builds a bare
// `GenericEffect` root, which is not a mode root.
let none = build_chained_resolved(&abilities, &[], ObjectId(1), PlayerId(0)).unwrap();
assert_eq!(none.modal_instruction_ordinal, None);
}

/// PROVENANCE PIN for `ResolvedAbility::modal_instruction_ordinal`: exactly
/// ONE non-test writer in the whole engine crate.
///
/// The field's meaning ("this node begins a new CR 700.2 instruction") is only
/// sound while `build_chained_resolved` β€” the one function that linearizes
/// selected modes into a chain β€” is its only author. A second writer would let
/// a non-mode-root claim a mode boundary and reset the chain-local tracked-set
/// identity mid-instruction.
///
/// Classification is by WRITE, not by name occurrence: the identifier also
/// appears at every exhaustive `ResolvedAbility` literal as `: None` (a
/// default, not a write) and at each of the eight exhaustive destructures.
///
/// Test regions are excluded by the `#[cfg(test)] mod` boundary, not by
/// filename β€” a filename-keyed scan of this crate has produced a wrong census
/// before (13 "src" sites that were all inside `#[cfg(test)] mod tests`).
#[test]
fn modal_instruction_ordinal_has_exactly_one_non_test_writer() {
// Assembled so this test's own source cannot be counted.
let needle = format!("modal_instruction_{}", "ordinal");
let write_forms = [format!("{needle} = "), format!("{needle}: Some(")];
// POSITIVE CONTROL: `build_chained_resolved`'s OWN other write, five lines
// from the one under census, in the same non-test region of the same file.
// If the walk or the `#[cfg(test)]` cut ever stops reaching that function,
// this reads 0 and the "exactly 1 writer" assertion below would be
// counterfeit. Counted per file rather than crate-wide: the needle is
// written 17 times across the crate, a number that drifts with unrelated
// work, and a crate-wide pin would be a maintenance tax that measures
// nothing this row cares about.
let control = format!("sub_link = SubAbilityLink::{}", "SequentialSibling");
let control_file = "ability_utils.rs";

let src_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut files: Vec<std::path::PathBuf> = Vec::new();
let mut stack = vec![src_root];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).unwrap_or_else(|e| panic!("read {dir:?}: {e}")) {
let path = entry.expect("dir entry").path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "rs") {
files.push(path);
}
}
}
files.sort();
assert!(files.len() > 100, "reach-guard: the walk found the crate");

let mut writers: Vec<String> = Vec::new();
let mut uncut_writers = 0usize;
let mut control_hits = 0usize;
for path in &files {
let text = std::fs::read_to_string(path).expect("read source");
// Comment halves removed by the shared authority, so a needle written
// in prose is neither counted nor able to hide a deleted writer.
let code = crate::source_census::code_lines(&text);
let lines: Vec<&str> = code.lines().collect();
// Cut at the FIRST `#[cfg(test)]` THAT IS FOLLOWED BY `mod`, not at
// the first `#[cfg(test)]` full stop. This crate also `#[cfg(test)]`-
// guards individual `use` and `fn` items (`ability_utils.rs` has four
// before its test module), and an earlier draft of this scan located
// the first marker and then merely CHECKED whether it introduced a
// module β€” which made the cut silently degrade to "no cut at all" in
// exactly the files that need it. Measured: it counted this PR's own
// `effects/mod.rs` unit-test writers as production writers.
let end = lines
.iter()
.enumerate()
.position(|(i, line)| {
line.trim_start().starts_with("#[cfg(test)]")
&& lines[i + 1..]
.iter()
.find(|l| !l.trim().is_empty())
.is_some_and(|l| l.trim_start().starts_with("mod "))
})
.unwrap_or(lines.len());
let rel = path.display().to_string();
for (i, line) in lines.iter().enumerate() {
if write_forms.iter().any(|f| line.contains(f.as_str())) {
uncut_writers += 1;
if i < end {
writers.push(format!("{rel}: {}", line.trim()));
}
}
if i < end && rel.ends_with(control_file) {
control_hits += line.matches(control.as_str()).count();
}
}
}

// NEGATIVE CONTROL for the region cut itself: the same scan WITHOUT the
// `#[cfg(test)] mod` cut must find strictly more writers. Without this
// arm a broken cut is invisible whenever no test happens to write the
// field β€” and then the day one does, this row reds for the wrong reason.
assert!(
uncut_writers > writers.len(),
"NEGATIVE CONTROL: the `#[cfg(test)] mod` cut must actually be \
excluding test-module writers. uncut={uncut_writers} cut={}",
writers.len()
);
assert_eq!(
control_hits, 1,
"POSITIVE CONTROL: `build_chained_resolved`'s `SequentialSibling` write \
must be visible to this scan, or a zero writer count is counterfeit. \
control_hits={control_hits}"
);
assert_eq!(
writers.len(),
1,
"CR 700.2: `modal_instruction_ordinal` must have exactly one non-test \
writer (`build_chained_resolved`). writers: {writers:#?}"
);
assert!(
writers[0].contains("ability_utils.rs"),
"the one writer must be `build_chained_resolved`, got {:?}",
writers[0]
);
}

#[test]
fn selected_mode_labels_follow_printed_order_and_preserve_repeats() {
let labels = selected_mode_labels(
Expand Down
2 changes: 2 additions & 0 deletions crates/engine/src/game/effects/additional_phase.rs
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ mod tests {
count: QuantityExpr,
) -> ResolvedAbility {
ResolvedAbility {
detached_remainder: crate::types::ability::DetachedRemainder::NoProducer,
effect: Effect::AdditionalPhase {
target,
phase,
Expand Down Expand Up @@ -289,6 +290,7 @@ mod tests {
target_choice_timing: crate::types::ability::TargetChoiceTiming::Stack,
description: None,
selected_mode_labels: Vec::new(),
modal_instruction_ordinal: None,
player_scope: None,
starting_with: None,
chosen_x: None,
Expand Down
Loading
Loading