Skip to content

Protecting image assets

Dani Alias edited this page Jul 9, 2026 · 1 revision

How to protect the image assets when shipping a game

Tutorial by Rudi Hammad

Image protection

  1. In TexturePacker, we drag and drop the image/spriteSheet we want to encrypt. For instance playerSprite.png. Now we set TextureFormat to PVR+zlib (.pvr.ccz), and in Content protection menu we generate a Key and click on "Save as globalKey". This allows to get it back at anytime clicking on "Use global key", so all future encrypted images will use. Let's say the generated key is 9512f9a50dc075a015f9a745ba60488b.

    This will generate playerSprite.pvr.ccz, which is the compressed and encrypted image. Let's store that images in Content/myAssets/playerSprite.pvr.ccz.

    NOTE: playerSprite.png should be removed from Content because we don't want to ship it with the game.

  2. In the AppDelegate.cpp file, we spread the key in 4 part parts using ZipUtils::setPvrEncryptionKeyPart(...)

bool AppDelegate::applicationDidFinishLaunching()
{
    ZipUtils::setPvrEncryptionKeyPart(0, 0x9512f9a5);

    // initialize director
    auto director = Director::getInstance();
    //
    //... more code
    // ...
    ZipUtils::setPvrEncryptionKeyPart(1, 0x0dc075a0);
    //
    //... more code
    // ...
    ZipUtils::setPvrEncryptionKeyPart(2, 0x415f9a745);
    //
    // ... more code
    // ...
    ZipUtils::setPvrEncryptionKeyPart(3, 0xba60488b);

    return true;
}
  1. In our game scene init.
bool MyScene::init()
{
    // ...
    auto sprite = Sprite::create("myAssets/playerSprite.pvr.ccz");
    sprite->setPosition(m_visibleSize/2);
    addChild(sprite);
    // ...
}

Tile map protection

For tile maps we start working as usual without any encryption. So we have for instance in Content/myAssets:

- myMap.tmx             <-- main Tiled map.
- surfaceTileset.tsx    <-- referenced by myMap.tmx  at <tileset firstgid="1" source="surfaceTileset.tsx"/>
- myTilset.png          <-- referencef by surfaceTileset at <image source="myTilset.png" width="120" height="120"/>

Once our level is done, we encrypt myTilset.png in TexturePacker as we did before. The result being myTilset.pvr.ccz. At this point we should replace myTileset.png with myTilset.pvr.ccz and edit surfaceTileset.tsx to reference that images <image source="myTilset.pvr.ccz" width="120" height="120"/>. Again, myTileset.png should be remove from Content so it doesn't ship with the game.

Now all we have to do is:

auto map = TMXTiledMap::create("myAssets/myMap.tmx");
addChild(map);

Summary

With the examples above, this what we ship when release the game

    Content
     |_ myAssets 
        -myMap.tmx             
        -surfaceTileset.tsx   
        -myTilset.pvr.ccz
        -playerSprite.pvr.ccz
        - etc...

So what did we achieve with this workflow?

  • We encrypted images so they can't be accessed by casual users when they look at the Content folder of the game.
  • The tilemap can be openned in tiled but it will be missing all the tiles since it can't display encrypted images.

This one protection layer that will work against most of the users trying to access the content. We can add another protector layer with XOR, but IMPORTANT: THIS WON'T WOKR FOR TILEMAPS, ONLY SPRITES.

Adding XOR to images

The first thing we need to do take each .png and encrypt it as .pvr.ccz as we did before and bundle them in a zip file. For instance:

    Content
    |_ Assets_world_1.zip
       |_ cityBackgound.pvr.ccz
       |_ enemy.pvr.ccz
       |_ ...

Now we need to XOR that zip. For this we can use this code in a new empty Visual Studio project:

#include <chrono>
#include <fstream>
#include <iostream>
#include <vector>
#include <filesystem>

constexpr unsigned char KEY = 0x5A; // Arbitrary key


int main()
{
    auto start = std::chrono::high_resolution_clock::now();

    std::cout << "Current directory: " << std::filesystem::current_path() << '\n';

    // Open the original ZIP
    std::ifstream input("../../Content/Assets_world_1.zip", std::ios::binary);
    if (!input) {
        return 1; 
    }

    // Read the entire ZIP file into memory
    std::vector<unsigned char> data( (std::istreambuf_iterator<char>(input)), std::istreambuf_iterator<char>());

    // XOR the entire archive
    for (auto& byte : data) {
        byte ^= KEY;
    }

    // Write the obfuscated archive
    std::ofstream output("../../Content/Assets_world_1.data", std::ios::binary);
    if (!output) {
        return 1;
    }

    output.write(reinterpret_cast<const char*>(data.data()), data.size());

    auto end = std::chrono::high_resolution_clock::now();

    std::cout << "Processed " << data.size() << " bytes in " << std::chrono::duration_cast<std::chrono::microseconds>(end - start).count() << " us\n";

    return 0;
}

Running this will create Content/Assets_world_1.data. Now we can delete Content/Assets_world_1.zip to avoid shipping it with the game. (NOTE: the input/output path has ../../ because in this case the Visual Studio project, called EncryptZip.sln, is in a folder called EncryptZip in the main Axmol game directory, so we need to step out to levels to access Content.)

The next step is to decrypt the .data. So in our game scene init we'll do:

    // Decrypt the encrypted .data 
    auto encryptedData = FileUtils::getInstance()->getDataFromFile("Assets_world_1/encryptedTileMapAssets.data");
    constexpr unsigned char KEY = 0x5A;                                     // Same key set in the "stand alone" encryption project
    Data decryptedData = encryptedData;                                     // Copy the decrypted data to preserve the original
    for (size_t i = 0; i < decryptedData.size(); ++i) {
        decryptedData.data()[i] ^= KEY;
    }

    // Create the zip file from the decrypted data. 
    auto zip = ax::ZipFile::createWithData(std::move(decryptedData));

    // Create a ResizableBufferAdapter that we can reuse to extract the data
    std::vector<unsigned char> dataBuffer;
    ax::ResizableBufferAdapter<std::vector<unsigned char>>dataAdapter(&dataBuffer);

    // Create the sprite
    if (!zip->getFileData("cityBackgound.pvr.ccz", &dataAdapter)) {                // notice the image it self remains encrypted as .pvr.ccz
        AXLOG("ERROR AT DECRYPTION");                                              // This provided dataBuffer with the Byte data related to that image
    }
    
    auto image = std::make_unique<Image>();
    image->initWithImageData(dataBuffer.data(), dataBuffer.size());                // Now we can get that image from the the buffer, create a texture from it
    auto imageTexture = std::make_unique<Texture2D>();                             // and therefor create the sprite Sprite::createWithTexture(...)
    imageTexture->initWithImage(image.get());
    imageTexture->setAliasTexParameters();
    
    auto sprite = Sprite::createWithTexture(imageTexture.get());
    sprite->setPosition(Director::getInstance()->getVisibleSize()/2);
    addChild(sprite);

With this worflow we have added a second layer of security, so now a hacker would need to reverse engineer multiple things: first, decrypt the XOR .data, and if they succeed, they would need also to decrypt the pvr.ccz images that are protected with the key splitted in 4 parts.

About the tilemaps: unfortunately, we can't XOR the tilemaps because the xml can't read from memory the source files they need. So this is an example of how our Content would look like:

    Content
    |_ Assets_world_1.zip                               
       |_ cityBackgound.pvr.ccz 
       |_ enemy.pvr.ccz
       |_ ...
       
    |_ myAssets 
       |_myMap.tmx             
       |_surfaceTileset.tsx   
       |_myTilset.pvr.ccz

Where the first block, Assets_world_1, has the two layers of protection as we just saw: pvr.ccz encryption + XOR. And the second block, myAssets, one layer of protection: pvr.ccz encryption.

Clone this wiki locally