Our website uses cookies to enhance your browsing experience.
Accept
to the top
>
>
>
Lock, Java, and two nulls: XMage edition

Lock, Java, and two nulls: XMage edition

Jun 24 2025

A computer game written in Java is rare but always interesting. That's why we couldn't miss the opportunity to check the XMage project using a static analyzer. Let's explore what PVS-Studio detected in its source code.

About project

XMage is a free, open-source version of Magic: the Gathering. Players can battle using cards both locally with bots and online with friends or random players.

The project is written entirely in Java and has been in active development for over 10 years. Today, the repository contains more than 47,000 commits and 1.8 million code lines. Five years ago, our team has already analyzed XMage and even posted an article. Back then, the project was around 800,000 lines smaller, so we thought it was time for a fresh look.

What's important to mention is that the project uses various code quality tools, including static analyzers. So, we're doubly interested to see what our analyzer can detect. It's time to examine the findings!

Unit test is the staff of life

The eternal question that tears developers' minds, "If tests control the code quality, then what controls the quality of the tests themselves?"

Let's examine detected errors in the XMage unit tests.

Oops, wrong check

@Test
public void test_NamesEquals() {
  ....
  // same names (empty names can't be same)
  Assert.assertFalse(CardUtil.haveSameNames(
        "", 
        ""));
  Assert.assertFalse(CardUtil.haveSameNames(
        EmptyNames.FACE_DOWN_CREATURE.getObjectName(), 
        ""));
  Assert.assertFalse(CardUtil.haveSameNames(
        EmptyNames.FACE_DOWN_CREATURE.getObjectName(), 
        EmptyNames.FACE_DOWN_CREATURE.getObjectName()));
  Assert.assertFalse(CardUtil.haveSameNames(
        EmptyNames.FACE_DOWN_TOKEN.getObjectNam(), 
        ""));
  Assert.assertFalse(CardUtil.haveSameNames(
        EmptyNames.FACE_DOWN_TOKEN.getObjectNam(), 
        EmptyNames.FACE_DOWN_CREATURE.getObjectName())); // <=
  ....
}

The PVS-Studio warning: V6072 Two similar code fragments were found. Perhaps, this is a typo and 'FACE_DOWN_TOKEN' variable should be used instead of 'FACE_DOWN_CREATURE'. AliasesApiTest.java 30

The analyzer points out that developers might have used FACE_DOWN_TOKEN instead of FACE_DOWN_CREATURE in the highlighted line.

The test fragment checks whether "empty names" are compared correctly. Logically, empty names should yield a negative result when compared to identity, regardless of whether they reference the same constant. That's what the comment above says as well.

It looks like a case of an odd copypasta. FACE_DOWN_CREATURE is compared with itself, but FACE_DOWN_TOKEN is not.

After altering the constant, the unit test starts to work as expected.

Uneven contest

@Test
public void test_Simple_LongGame() {
    ....
    addCard(Zone.LIBRARY, playerA, "Mountain", 10);
    addCard(Zone.LIBRARY, playerA, "Forest", 10);
    addCard(Zone.LIBRARY, playerA, "Lightning Bolt", 20);
    addCard(Zone.LIBRARY, playerA, "Balduvian Bears", 10);
    //
    addCard(Zone.LIBRARY, playerB, "Mountain", 10);
    addCard(Zone.LIBRARY, playerA, "Forest", 10);        // <=
    addCard(Zone.LIBRARY, playerB, "Lightning Bolt", 20);
    addCard(Zone.LIBRARY, playerB, "Balduvian Bears", 10);
    ....
}

The PVS-Studio warning: V6072 Two similar code fragments were found. Perhaps, this is a typo and 'playerB' variable should be used instead of 'playerA'. SimulationPerformanceAITest.java 60

I think when you see the arrow in the code snippet above, you understand why I've added it in this article :)

The test checks that AI vs AI matches are properly handled from a performance standpoint—though, not so fair. Gentleman A received more forest cards than Gentleman B. Oh again: it looks like a sloppy copypasta. But there is nothing serious: the test was passing before the fix and will remain so afterward.

Typos & Bizarre issues

A logical turmoil

The PVS-Studio warning: V6008 Null dereference of 'permanent'. ThreeTreeScribe.java 78

@Override
public boolean checkTrigger(GameEvent event, Game game) {
    ....
    Permanent permanent = zEvent.getTarget();
    return (    permanent != null 
            || !permanent.isControlledBy(getControllerId()))  // <=
            && (permanent.getId().equals(getSourceId()) 
                || permanent.isCreature(game));
}

Here's an obvious invalid condition in this code snippet that's hard not to smile at a little bit. Inside return, developers have dereferenced permanent if it's null.

The next example is also related to V6008 diagnostic rule and also about conditions. However, this time, there are two warnings for one code fragment at once:

V6008 Potential null dereference of 'player'. DelifsCone.java 104

V6008 Potential null dereference of 'permanent'. DelifsCone.java 104

@Override
public boolean apply(Game game, Ability source) {
    Player player = game.getPlayer(source.getControllerId());
    Permanent 
        permanent = game.getPermanent(....);
    if (player != null || permanent != null) {
        player.gainLife(permanent.getPower().getValue(), game, source);
        return true;
    }
    return false;
}

Checking only one object for null while dereferencing both of them at once looks like not so right approach. Clearly, the condition should have used && instead of ||. Btw, we encounter such kind of typos quite often in various projects.

Close enough...

static {
    for (int count = 1; count <= 6; count++) {
        FilterCard filter = new FilterCreatureCard(
                "creature card that's exactly " +
                        CardUtil.numberToText(count) +
                        " color" + (count > 0 ? "s" : "")
        );
        filter.add(new EvolvingDoorPredicate(count));
        filterMap.put(count, filter);
    }
}

The PVS-Studio warning: V6007 Expression 'count > 0' is always true. EvolvingDoor.java 76

Indeed, it's a strange condition. Looking at the constructed string, we can see that regardless of the loop's value, the ending "s" is added to the word "color." If we replace 0 with 1 in the code, grammar will be correct, and the loop will work as intended.

We did the best we could

The PVS-Studio warning: V6060 The 'table' reference was utilized before it was verified against null. TableController.java 725

private void startGame(UUID choosingPlayerId) throws GameException {
    try {
        ....
    } catch (Exception ex) {
        logger.fatal("Error starting game table: " + table.getId(), ex);
        if (table != null) {
            managerFactory.tableManager().removeTable(table.getId());
        }
        .... 
    }
}

It's a good deal to check for null—but all in good time. Well, developers use table first and only then check it for null.

Another file catches the same warning:

V6060 The 'enchantment' reference was utilized before it was verified against null. PublicEnemy.java 79

@Override
public boolean applies(Permanent permanent, Ability source, Game game) {
    Permanent 
        enchantment = game.getPermanent(source.getSourceId());
    Permanent 
        enchantedCreature = game.getPermanent(enchantment.getAttachedTo());
    if (enchantment == null || enchantedCreature == null) {
        return false;
    }
    if (permanent.isControlledBy(enchantedCreature.getControllerId())) {
        return false;
    }
    return true;
}

Developers use the enchantment object to create enchantedCreature, and only then check for null.

Mapped-ish

The PVS-Studio warning: V6012 The '?:' operator, regardless of its conditional expression, always returns one and the same value 'card'. SkyclaveApparition.java 108

@Override
public boolean apply(Game game, Ability source) {
    .... 
    Set<UUID> owners = new HashSet<>();
    int totalCMC = exile
            .getCards(game)
            .stream()
            .filter(Objects::nonNull)
            .map(card -> owners.add(card.getOwnerId()) ? card : card) // <=
            .mapToInt(MageObject::getManaValue)
            .sum();
    ....
    return true;
}

Here's a rather odd code fragment that can easily bewilder someone who is unfamiliar with this class. Either devs would like to do something with the card object in one of the branches of the ternary operator but forgot—or they just wanted to add all stream objects to the owners instance of the Set class. If it's the second case, developers can use the peek method instead of map.

peek returns the input data stream while additionally performing some actions on its elements.

map returns the data stream with the output of applying the specified method to the input data stream.

Here we "null" again

Let's train our eyes and try to do a little interprocedural analysis on our own.

Look at this code fragment:

@Override
public boolean apply(Game game, Ability source) {
    ....
    Card card = getCard(player, toReveal, game);
    ....
    player.moveCards(card, Zone.BATTLEFIELD, source, game);
    ....
    Permanent creature = game.getPermanent(card.getId());  // <=
    ....
}

The PVS-Studio warning: V6008 Potential null dereference of 'card'. AspiringChampion.java 84

Now take a peek at the getCard method where card is obtained. One of the method execution paths may return null:

private static Card getCard(Player player, Cards toReveal, Game game) {
    for (Card card : player.getLibrary().getCards(game)) {
        toReveal.add(card);
        if (card.isCreature(game)) {
            return card;
        }
    }
    return null;
}

In the implementations of the moveCards method, which uses the card object, this is taken into account:

@Override
public boolean moveCards(Card card, ....) {
    Set<Card> cardList = new HashSet<>();
    if (card != null) {
        cardList.add(card);
    }
    return moveCards(cardList, toZone, source, game, 
                     tapped, faceDown, byOwner, appliedEffects);
}

If we go back to the original code snippet, we can see that in the highlighted line, card is accessed without checking whether it might be null.

Recall: Java basics through analyzer warnings

Be careful with comparing String objects

The PVS-Studio warning: V6013 Strings 'referenceName' and '""' are compared by reference. Possibly an equality comparison was intended. UrzasHotTub.java 112

private boolean sharesWordWithName(String str) {
    if (referenceName == null || referenceName == "") {
        return false;
    }
    . . . .
}

It's a quite common error encountered in various projects—especially ancient ones. Strings aren't primitives, so == compares object references, not objects themselves. Even with Java's String Pool, there's no guarantee that two different string variables will reference the same object. The behavior is non-deterministic—so is the result. We should use the equals method—and especially here, the isEmpty method.

Be careful with deserialization

The PVS-Studio warning: V6087 InvalidClassException may occur during deserialization. Non-serializable ancestor 'RoomImpl' is missing an accessible no-arg constructor. GamesRoomImpl.java 32

public class GamesRoomImpl extends RoomImpl implements GamesRoom, Serializable {
    ....
}

The class is serializable, but its first non-serializable superclass doesn't have a public no-arg constructor:

public abstract class RoomImpl implements Room {
    ....
    public RoomImpl(ChatManager chatManager) {
        roomId = UUID.randomUUID();
        chatId = chatManager.createChatSession("Room " + roomId);
    }
    ....
}

Why is this a problem?

We've previously published an article where we took a brief look under the hood of Java native serialization and deserialization (click here). I think it'll be useful if I show a small quote from it:

The deserializable object is created not via its constructor but via the constructor of its first non-serializable superclass. This makes sense because the deserializable object and its superclasses that support serialization are restored from the byte stream. It's meaningless to execute code from constructors and initializers at this point. We're left only with non-serializable superclasses, so to initialize them, we call the constructor of the first non-serializable superclass of the restored object.

If the required constructor is missed during deserialization, an InvalidClassException exception will be thrown with the no valid constructor message.

So, as the saying goes: "Call yourself Serializable—then have a superclass with a public no-arg constructor."

Be careful with synchronization

The PVS-Studio warning: V6102 Inconsistent synchronization of the 'continuousRuleModifyingEffects' field. Consider synchronizing the field on all usages. ContinuousEffects.java 43

public class ContinuousEffects {
    ....
    private ContinuousEffectsList<....> 
      continuousRuleModifyingEffects = new ContinuousEffectsList<>();
    ....
    public synchronized void removeInactiveEffects(Game game) {
        ....
        continuousRuleModifyingEffects.removeInactiveEffects(game);
        ....
    }
    ....
    public synchronized void removeEndOfCombatEffects() {
        ....
        continuousRuleModifyingEffects.removeEndOfCombatEffects();
        ....
    }
    ....
    public boolean preventedByRuleModification(....) {
        for (.... effect : continuousRuleModifyingEffects) {     // <=
            if (!effect.checksEventType(event, game)) {
                continue;
            }
            ....
        }
        ....
    }
    ....
    public synchronized void addEffect(....) {
        ....
        switch (effect.getEffectType()) {
            ....
            case CONTINUOUS_RULE_MODIFICATION:
                continuousRuleModifyingEffects.addEffect(....);
                break;
            ....
        }
    }
    ....
}

I've shown only a couple of spots in the example, but in the code, the continuousRuleModifyingEffects field is used within a synchronized block in eight methods, while in the ninth, it is not. I don't include each of the code fragments to avoid clutter.

Since the methods through which the field is interacted with are marked as synchronized, it's assumed that the interaction with the object takes place in a multithreaded block. However, interaction with the continuousRuleModifyingEffects field in the preventedByRuleModification method is unsynchronized in any way.

The collection is modified in one thread in one of the synchronized methods and read in another in a non-synchronized method. This can lead to the following problems:

  • a ConcurrentModificationException exception may be thrown because this collection inherits from ArrayList, and in the unsynchronized method iterating over it is done via for-each;
  • a thread may read the object while it is in an invalid state due to concurrent modifications by another thread;
  • a visibility issue may occur: the thread reading the collection might not see the changes made by another thread.

To avoid such problems, access to the collection should be synchronized. This can be done either through synchronized external invocations or through the use of collections that are already thread-safe.

Be careful with equals and hashCode

The equals method is overridden, but hashCode is not.

public class CounterView implements Serializable {
    . . . .

    @Override
    public boolean equals(Object other) {
        if (other == this) {
            return true;
        }
        if (other == null) {
            return false;
        }
        if (!(other instanceof CounterView)) {
            return false;
        }
        CounterView oth = (CounterView) other;
        return
                (count == oth.count) &&
                        (name.equals(oth.name));
    }
}

The PVS-Studio warning: V6049 Classes that define 'equals' method must also define 'hashCode' method. Class: 'CounterView'. CounterView.java 10

Personally, I feel that the rule about overriding equals and hashCode is etched in every Java developer's mind. Yet, there are several warnings in the project indicating violations of their override contract. That's why, I think it's worth repeating this information. Besides, it's possible that our articles may be read not only by seasoned developers but also by Java beginners. So, let's go over the basics once again.

There is a set of rules for overriding the equals and hashCode methods. I won't mention all of them here, only hashCode:

If two objects are equal according to the equals (Object) method, then calling the hashCode method on each of the two objects must produce the same integer result.

To comply with this rule, developers need to override the equals and hashCode methods in a pair. This is also what the documentation for the equals method says:

It is generally necessary to override the hashCode method whenever this method is overridden, so as to maintain the general contract for the hashCode method, which states that equal objects must have equal hash codes.

What is the purpose of such a rule? It ensures obtaining valid results when interacting with data structures that use a hash table under the hood. For example, HashMap uses the hashCode and equals methods to check if an object exists in a structure and then retrieve it. Without the overridden hashCode method, the class can duplicate objects in HashSet, which we definitely don't want.

Even if the object isn't intended to be used immediately in hash-based structures, it doesn't mean the situation won't change. The best option is to follow the documentation recommendations. So, developers can save time searching for bugs if they avoid problems right away.

Be careful with closing resources

The PVS-Studio warning: V6114 The 'ComputerPlayerMCTS' class contains the 'threadPoolSimulations' Closeable field, but the resources that the field is holding are not released inside the class. ComputerPlayerMCTS.java 39

public class ComputerPlayerMCTS extends ComputerPlayer {
  ....
  private ExecutorService threadPoolSimulations = null;
  ....
  protected void applyMCTS(....) {
    if (this.threadPoolSimulations == null) {
      ....
      this.threadPoolSimulations = new ThreadPoolExecutor(....);
    }
    ....
    try {
      List<Future<Boolean>> 
        runningTasks = threadPoolSimulations.invokeAll(tasks, 
                                                       thinkTime, 
                                                       TimeUnit.SECONDS);
      ....
    }
    ....
  }
  ....
}

The warning indicates that the Closable object is not closed. Since when did ExecutorService become Closable? Starting with Java 19 , ExecutorSerivce implements the AutoClosable interface, which allows ExecutorService to be seamlessly used in try-with-resource.

Note that the project is minimally supported on Java 8. I've built and tested it on version 21, which is why the warning about Closable appears :) This means the project itself doesn't assume ExecutorService implements Closeable. Nevertheless, its resources should be properly shut down, and that's not happening here. This class documentation clearly states this:

An unused ExecutorService should be shut down to allow reclamation of its resources.

The same documentation has examples of how we can correctly shut down an instance of ExecutorService.

Things that made us chuckle

After reviewing a large amount of code, we get a little tired. And maybe everyone wants to chill and enjoy something. The next couple of snippets struck me as entertaining, so I think I can share them with you :)

Super-duper technology

public class Session {
  ....
  // TODO: enableafter full research
  private static final boolean 
    SUPER_DUPER_BUGGY_AND_FASTEST_ASYNC_CONNECTION = false; 
  ....
  public void fireCallback(final ClientCallback call) {
    ....
    try {
      if (valid && callBackLock.tryLock(50, TimeUnit.MILLISECONDS)) {
        ....
        boolean sendAsync = SUPER_DUPER_BUGGY_AND_FASTEST_ASYNC_CONNECTION
                && call.getMethod().getType().canComeInAnyOrder();        // <=
        ....
      }
      ....
    }
    ....
  }
  ....
}

The PVS-Studio warning: V6019 Unreachable code detected. It is possible that an error is present. Session.java 438

The warning indicates that the code fragment is unreachable. When I looked at it, I couldn't help but smile. I don't encounter such expression like SUPER_DUPER_BUGGY_AND_FASTEST_ASYNC_CONNECTION every day. However, in the same class, developers have an extensive discussion about implementing asynchronous data transfer from the server to the client during gameplay. A huge wall of comments outlines the pros and cons of integrating this into the game. The comment "enable after full research" next to the field declaration refers exactly to that :)

Just pass it on and never use it, huh?

// TODO: investigate why we just discard sourceId and provide source directly?
public ZoneChangeGroupEvent(...., UUID sourceId, Ability source, ....) {
        super(....);
        this.fromZone = fromZone;
        this.toZone = toZone;
        this.cards = cards;
        this.tokens = tokens;
        this.source = source;
}

The PVS-Studio warning: V6022 Parameter 'sourceId' is not used inside constructor body. ZoneChangeGroupEvent.java 23

When a team works on a large project, it's easy for developers to never come across some parts of the code their colleagues write. The situation above feels exactly like that.

The analyzer has detected that sourceId isn't used in the constructor. Interestingly, one of the developers had already noticed this and left a TODO comment about it. Honestly, that little note made me smile he-he.

Game over

I hope you found this the XMage exploration as interesting as I did—even if only a little bit. I'm glad that this long-running project is still going strong. I'm also happy that there are such fascinating projects in Java that are full-fledged games.

By the way, we've published an article where we've dived into bugs detected by the analyzer in a Minecraft mod and analyzed a game engine written in Java.

If you're curious about what the analyzer can do and want to give this tool a try, I'll leave a link for you.

That's all for now. See ya soon!

Posts: articles

Poll:

Subscribe
and get the e-book
for free!

book terrible tips


Comments (0)

Next comments next comments
close form
check circle
Message submitted.

Your message has been sent. We will email you at


If you do not see the email in your inbox, please check if it is filtered to one of the following folders:

  • Promotion
  • Updates
  • Spam