Skip to content

Decouple the replacement zone scan from PERFORMANCE_MODE#11328

Open
liamiak wants to merge 1 commit into
Card-Forge:masterfrom
liamiak:decouple-performance-mode-replacement-scan
Open

Decouple the replacement zone scan from PERFORMANCE_MODE#11328
liamiak wants to merge 1 commit into
Card-Forge:masterfrom
liamiak:decouple-performance-mode-replacement-scan

Conversation

@liamiak

@liamiak liamiak commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

PERFORMANCE_MODE currently gates two unrelated behaviours:

site what it does documented?
Spell.java:101 skips the LKI re-control when activator != controller yes — this is what the settings text describes and warns about
ReplacementHandler.java:110 the Tap/Untap zone skip from #11160 no — not mentioned in the UI at all

The settings description reads:

Disables additional static abilities checks to speed up the game engine. (Warning: breaks some 'as if had flash' scenarios when casting cards owned by opponents).

That describes only the first. And it's accurate — skipping the re-control means checkActivatorRestrictions evaluates "You" against c.getController(), which is still the opponent, so a MayPlay permission granted to you doesn't match. That's the reported Petty Larceny behaviour (its exiled cards become unplayable, lands included).

So the second behaviour — a large, rules-derived win — is hidden behind a checkbox users are correctly told will break their cards. Not everyone enables it, which means #11160 is inert for most users and never runs in CI.

Measurements

3 headless 4-player games, "Big 240531" mirror, normalised per call (sims aren't deterministic, so game length varies between runs):

before after
Tap scan 2788.6 µs 194.4 µs
ProduceMana scan 2749.2 µs 178.7 µs
RE-list rebuilds per scan 1681 34

For comparison, on the same workload the Spell.canPlay branch fires on 25.8% of calls at 161.8 µs each — about 3.8s, against ~140s for the scan restriction. The safe half is roughly 97% of the benefit.

What this changes

The zone restriction becomes unconditional and derives its zones from the loaded card pool rather than a hardcoded pair. Per CR 113.6 these replacements only function from the battlefield (or the command zone, for Effects); a card that needs one active elsewhere declares ActiveZones$, and that widens the scan for that event.

PERFORMANCE_MODE keeps its current default and now governs only the Spell.canPlay branch — exactly what its description says.

It also closes a gap

The current skip returns before replacementEffect.zonesCheck(cardZone), so it ignores ActiveZones$ entirely. A custom card that declared ActiveZones$ Graveyard on a Tap replacement would still be skipped — the flag's stated purpose ("in case a custom card wants one active from elsewhere") isn't actually served today; it's all-or-nothing. Verified by adding such a card and watching the computed set:

stock pool:  Tap=[Battlefield, Command]
+ probe:     Tap=[Graveyard, Battlefield, Command]     (Untap/ProduceMana unchanged)

Notes

  • Includes unit tests for the zone derivation (defaults, declaration widening, event isolation, non-hot events unrestricted).
  • The traversal itself is unchanged; this only avoids the per-card getReplacementEffects() rebuild, which measured as ~78% of scan cost. Restricting the traversal is a possible follow-up.
  • Supersedes AI: include ProduceMana in the performance-mode replacement zone skip #11327, which extends the hardcoded list to ProduceMana. If you'd rather take the small version first, that one stands alone and I'll rebase this on top.
  • Happy to instead keep the restriction behind a new, separately-named flag if you'd prefer an escape hatch for undeclared custom cards — I went with unconditional because the declaration mechanism already exists and makes the flag redundant, but it's your call.

AI credit: Claude (opus 4.8) is doing a the work here.

Edit/note from non-AI: I'm mainly pushing back on things that don't quite make sense to me (this one took a bit of settling to get to something that seemed an actual fix instead of symptom remediation (which could rhyme with bloat).

PERFORMANCE_MODE currently gates two unrelated things:

  Spell.java:101          skips the LKI re-control when the activator is
                          not the controller. This is what the settings
                          description documents, and what its warning is
                          about: it breaks MayPlay on cards you do not
                          control (e.g. Petty Larceny's exiled cards).

  ReplacementHandler:110  the Tap/Untap zone skip from Card-Forge#11160. Not
                          mentioned in the settings UI at all.

Measured over 3 headless 4-player games, the second is worth ~140s of a
~155s replacement-scan cost; the first is worth ~3.8s and carries a known
correctness bug. Users are told the flag will break their cards, which is
true of the half they did not ask for, so nearly nobody turns it on and
the scan optimisation is inert by default - it is not exercised by CI
either.

This separates them. The zone restriction becomes unconditional and
derives its zones from the card pool instead of a hardcoded pair:
per CR 113.6 these replacements only function from the battlefield (or
the command zone, for Effects), and any card that declares ActiveZones$
widens the scan for that event. That also fixes a gap in the current
skip, which returns before zonesCheck and so ignores such a declaration
entirely - a custom card could not opt in even by asking correctly.

PERFORMANCE_MODE keeps its default and now governs only the Spell.canPlay
branch, which is exactly what its description says it does.

Per-call measurements (game length varies between runs):

  Tap          2788.6 -> 194.4 us/scan
  ProduceMana  2749.2 -> 178.7 us/scan
  RE-list rebuilds per scan: 1681 -> 34

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@tool4ever

Copy link
Copy Markdown
Contributor

interesting idea, but at the same time pretty over-engineered
especially if it's only for something easily hardcoded
the right approach is just getting rid of the overhead instead of trying to detect it: #11058

@liamiak

liamiak commented Jul 23, 2026

Copy link
Copy Markdown
Contributor Author

On #11058 being the right approach here — one profiling note, in case it's useful for sequencing. I profiled one cantHappenCheck(Tap) on a 2-player board of ~560 cards — so 560 iterations of the visitor loop — and split where the ~465µs goes:

  • the traversal itself (forEachCardInGame walking the zones, empty body): ~4.5µs, ~1%
  • c.getReplacementEffects() rebuilding each card's effect list inside the loop: ~370µs, ~79%

forEachCardInGame is already allocation-free by construction, and #11058 as drawn streams that traversal — so it targets the 1%. The 79% is the per-card getReplacementEffects() rebuild, which a stream iterates the same way; flatMap over the zones still calls it once per card. So streaming this entry point shouldn't move this path much on its own.

The thing that would actually retire the skip — for every event, not just these three — is making that per-card rebuild cheap: either caching the RE list per card the way keywords already are (getCachedKeywords/updateKeywordsCache), or a per-event replacement index like activeTriggers. Both are bigger than this and I don't think they're on a branch yet; the LKI/preList path in getReplacementList makes a naive per-card cache tricky (it computes effects against a hypothetical battlefield state, not the live card). So I don't think this contradicts the #11058 direction — just that this particular scan's cost is somewhere the traversal rework won't reach, that I can see.

@tool4ever

Copy link
Copy Markdown
Contributor

Ok, thanks for checking
that means #11323 might not be needed and I need to take a look at #11314 too

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants