← Home

cracking conquest of elysium 5


intro

Pasted image 20260909164741.png

when i first saw it, i thought the algorithm was incredibly basic and foolish.

WEIRD_SUM_OF_G1_TO_G4 = (int)(3 * (G4 & 0x7F) + G1 + 52 * v9 + 19 * v50 + 15 * v45 + 11 * (G3 & 0x7F) + 7 * (G4 / 128)) % 10000;
sub_5555556E37A0(a1: G3);
if ( G5 == WEIRD_SUM_OF_G1_TO_G4 )
    // your key is valid

it checks the weighted sum of the first 4 sections of the key G1-G2-G3-G4, then ensures it equals to G5.

i got a little cocky, stating "the algorithm is so braindead that 0000-0000-0000-0000-0000 works". in my defense, that was true, as well as the key 000000000. both those keys let you get past the menu.

but what i didn't realize at the time was that the CoE5 devs were sneaky gremlins that added various key checks in unassuming parts of the game, causing glitchy behavior — intended to make you think it was a bug and drive you crazy if you used a fake key.
it was kind of cute, and it also made for a really fun crack. i would totally do the same (just to be diabolical) if i were to make an offline key checker for my own games.

i would need to put on my investigative glasses and lock in to crack this in a weekend. so join me as we dig through decompiled code and obfuscated algorithms in the name of research!

the first wall i hit with the key 000000000 was actually playing a game. when i start a single-player game, before the first turn, it would unexpectedly end immediately with the message "Team 0 has won".


let me introduce you to my classification system for CoE5 keys. you may not know it (i didn't at the time), but there are actually 5 levels of CoE5 keys. the key 000000000 is a level 1 key — meaning it only passes the level 1 check.

Level Function Check Effect if failed
1 validate_key G5 == weighted_sum(G1,G2,G3,G4) % 10000 Can't enter menu
2 ??? ??? ???
3 ??? ??? ???
4 ??? ??? ???
5 ??? ??? ???

we will fill this table out as we discover more!


level 2

at first i thought this "Team 0 has won" was a bug. so, i enabled the logs; which is a variable set to 0 at the top of main. you can just patch it to 1:

int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
    // [COLLAPSED LOCAL DECLARATIONS. PRESS NUMPAD "+" TO EXPAND]
    while ( &v266 != (__int64 *)&v249 )
    ;
    v3 = 0;
    PRINT_DEBUG_LOGS = 1; // HERE
    memset(v261, 0, sizeof(v261));
    v6 = (char *)&STACK[0x6058];
    v262 = 0;
    debug_log(format: "CoE %s starting\n", "5.39");
    ...
}

but the logs didn't reveal any errors. when i entered a legit key, i was able to start the game with no problems on the same machine, so that led me to reason that there must be a second key check.

i dug around main() where i last found the level 1 check, and even uncovered a spelling mistake!

if ( (unsigned int)file_exists(a1: (const char *)&STACK[0x5378]) == 0 )
    debug_log(format: "keyfile '%s' dosen't exist\n", (const char *)&STACK[0x5378]); // dosen't
if ( (unsigned int)file_exists(a1: (const char *)&STACK[0x5378]) != 0 )
{
    v211 = fopen(filename: (const char *)&STACK[0x5378], modes: "rb");
    v213 = v211;
    if ( v211 != nullptr )
    {
        v295 = v212;
        fscanf(stream: v211, format: "%d-%d-%d-%d-%d", &G1, &G2, &G3, &G4, name); // should be called G5; my bad
        fclose(stream: v213);
    }
}
if ( G1 == 0 ) ...
if ( (unsigned int)validate_key(G1, G2, G3, G4, G5: *(int *)name) == 0 )
{
    showing_some_ui(a1: (unsigned int)"Invalid CD key", a2: v168, a3: G1, a4: v167, a5: v169, a6: v170);
    remove_coe5key_files();
    goto LABEL_254;
}

as you can see, it reads the keyfile then validates it right here in main:

validate_key(G1, G2, G3, G4, G5: *(int *)name)

but i had already accounted for this check (the level 1 check).

with no further success in main, i looked around the binary for strings. i suspected the other key check to be unrelated to this one in main, and my conjecture was confirmed when specifically "coe5key" yielded results. i found another function, find_and_validate_key_2, that opened %s/../coe5key and did a chain of SHIFT, AND, ADD.

v4 = fopen(filename: s, modes: "rb");
if ( v4 != nullptr )
{
    fclose(stream: v4);
    v5 = fopen(filename: s, modes: "rb");
    v6 = v5;
    if ( v5 != nullptr )
    {
        fscanf(stream: v5, format: "%d-%d-%d-%d-%d", &G1, &G2, &G3, &G4, &G5);
        fclose(stream: v6);
    }
}
v7 = (G1 ^ 0x27282) >> 1;
if ( (((unsigned __int8)G1 ^ 0x82) & 0x10) != 0 )
    v7 = ((G1 ^ 0x27282) >> 1) + (((G1 ^ 0x27282) >> 1) & 0x10) + (((G1 ^ 0x27282) >> 1) & 4) + 1000;
v8 = v7 >> 1;
if ( (v7 & 0x10) != 0 )
    v8 = ((v7 >> 1) & 0x14) + (v7 >> 1) + 1000;
v9 = v8 + v7 + (G1 ^ 0x82);
v10 = v8 >> 1;
if ( (v8 & 0x10) != 0 )
    v10 = (v8 >> 1) + ((v8 >> 1) & 0x10) + ((v8 >> 1) & 4) + 1000;
v11 = v10 + v9;
v12 = v10 >> 1;
if ( (v10 & 0x10) != 0 )
    v12 = (v10 >> 1) + ((v10 >> 1) & 0x10) + ((v10 >> 1) & 4) + 1000;
v13 = v12 + v11;
v14 = v12 >> 1;
...
v33 = v31 + v32;
v34 = v32 >> 1;
if ( (v32 & 0x10) != 0 )
    LOBYTE(v34) = v34 + (v34 & 0x10) + (v34 & 4) - 24;
result = (void *)((v33 + (_BYTE)v34) & 0x7F);
if ( (G2 & 0x7F) != (_DWORD)result )
{
    // you lose! the return value of this function is irrelevant,
    // the important thing is we memset the fact that the key is invalid
    debug_log(format: "barb qqq 32\n");
    return memset(s: &g_key_corruption_memblock, c: 0, n: 0xF1080u);
}

yep. this find_and_validate_key_2 function is randomly called in one place: on line 4724 of the main game processing function, after_game_turn.
it loads your key file, performs a chain of math on G1, and validates its low byte against G2 & 0x7F.
in order words, we've discovered the LEVEL 2 CHECK! it forces a new relationship between the G1 and G2 sections of the key.

the log line "barb qqq 32" is completely meaningless to most. it is designed to blend in with the other logs, looking totally unassuming.
but it actually signifies to anyone reading the logs that:

  • your key is invalid,
  • and 987264 bytes of critical game data have just been memset to 0.

after asking "WHAT DOES 'barb qqq 32' MEAN!?!", i have concluded it's just the devs trying to cause confusion, having a laugh at anyone reverse-engineering the function.

let's add this new discovery to the table, shall we?

Level Function Check Effect if failed
1 validate_key G5 == weighted_sum(G1,G2,G3,G4) % 10000 Can't enter menu
2 find_and_validate_key_2 G2 & 0x7F == hash(G1) Immediate "Team 0 wins" upon starting game
3 ??? ??? ???
4 ??? ??? ???
5 ??? ??? ???

level 3

with that, i thought i was in the clear. i started a game with my keygen-generated Level 2 key, and i played a few turns.
patting myself on the back for being such a great reverse engineer, i was ready to wrap up this project.

but of course,
that's not what happened.

after playing a few turns, i hit "next turn", and

Pasted image 20260909221448.png

huh. that's weird. i didn't leave my citadel undefended. what happened?

following that, one-by-one, every single player on the map was "eliminated due to no citadel", and i knew something was up.

checking the logs, nothing too suspicious, just a bunch of map updates.

cas♥ /home/cas/apps/ConquestOfElysium5/coe5
...
mist: spread from 353,6
mist: spread from 358,7
mist: spread from 358,12
mist: spread from 351,13
mist: spread from 352,14
mist: spread from 357,14
mist: spread from 360,15
mist: spread from 493,16
mist: spread from 356,17
mist: spread from 357,17
mist: spread from 502,20
mist: spread from 359,21
mist: spread from 373,21
mist: spread from 389,21
mist: spread from 388,24
mist: spread from 505,24
mist: spread from 496,26
mist: spread from 154,30
mist: spread from 151,32
a 67
AGT fluff
updatevoid
bt3: Giant Jellyfish dies [unr -1][vic 63]
hit: dead unr63 (Giant Jellyfish)
msg to 24: '1 unit drowned in a Plain.'
bt3: Asp Turtle dies [unr -1][vic 88]
hit: dead unr88 (Asp Turtle)
msg to 24: '1 unit drowned in a Plain.'
bt3: Hippocampus dies [unr -1][vic 118]
...

so... this part of the crack was the largest struggle, and it all comes back to the first assumption i'd made.

getting nowhere searching the binary for other strings, i took a closer look inside validate_key, the level 1 function. i was — like you are being now — assaulted with a vast amount of confusing math operations.

// LEVEL 1 CHECK
__int64 __fastcall validate_key(int G1, int G2, int G3, signed int G4, int G5)
{
    // [COLLAPSED LOCAL DECLARATIONS. PRESS NUMPAD "+" TO EXPAND]
    useless_var_4 += obfuscated_hash_function(a1: dword_5555599C9628, a2: G2);
    useless_var_1 += obfuscated_hash_function(a1: G1, a2: useless_var_4);
    useless_var_5 += obfuscated_prng_function(a1: G1 + G2, a2: dword_555563DF3934);
    v7 = useless_var_5;
    if ( (useless_var_1 & 1) != 0 )
        useless_var_2 += obfuscated_hash_function(a1: G1, a2: G2);
    useless_var_3 += obfuscated_prng_function(a1: G1, a2: G2);
    dword_5555627501E8 = G1;
    useless_var_4 += obfuscated_hash_function(a1: dword_5555599C9628, a2: G3);
    useless_var_1 += obfuscated_hash_function(a1: G1, a2: useless_var_4);
    v8 = obfuscated_prng_function(a1: G1 + G3, a2: dword_555563DF3934) + v7;
    useless_var_5 = v8;
    if ( (useless_var_1 & 1) != 0 )
        useless_var_2 += obfuscated_hash_function(a1: G1, a2: G3);
    useless_var_3 += obfuscated_prng_function(a1: G1, a2: G3);
    v9 = G1 & 0x7F;
    v10 = G2 / 128;
    useless_var_4 += obfuscated_hash_function(a1: dword_5555599C9628, a2: G2 / 128);
    useless_var_1 += obfuscated_hash_function(a1: v9, a2: useless_var_4);
    v11 = obfuscated_prng_function(a1: v9 + G2 / 128, a2: dword_555563DF3934) + v8;
    useless_var_5 = v11;
    if ( (useless_var_1 & 1) != 0 )
        useless_var_2 += obfuscated_hash_function(a1: v9, a2: v10);
    v12 = obfuscated_prng_function(a1: v9, a2: v10);
    dword_555563DE9C88 = G2 / 128;
    useless_var_3 += v12;
    v13 = _mm_shuffle_epi32(_mm_cvtsi32_si128(G1), 0);
    v14 = 0;
    si128 = _mm_load_si128((const __m128i *)&xmmword_55555586AC00);
    v16 = _mm_load_si128((const __m128i *)&xmmword_55555586AC10);
    v17 = 0;
    do
    {
        v18 = si128;
        ++v14;
        si128 = _mm_add_epi32(si128, v16);
        v17 = _mm_add_epi32(v17, _mm_xor_si128(v18, v13));
    }
    while ( v14 != 250 );
    
    ... // tons and tons more useless assignments and function calls
    
    // the actual validate line
    WEIRD_SUM_OF_G1_TO_G4 = (3 * (G4 & 0x7F) + G1 + 52 * v10 + 19 * G2_AND_0x7F + 15 * v48 + 11 * (G3 & 0x7F) + 7 * (G4 / 128)) % 10000;
    obfuscated_hash_function(a1: G3, a2: G4);
    if ( G5 == WEIRD_SUM_OF_G1_TO_G4 )
    dword_555563DE9C80 = 1;
    obfuscated_hash_function(a1: G2, a2: G4);
    v43 = G3 * G2;
    useless_var_4 += obfuscated_hash_function(a1: dword_5555599C9628, a2: G1);
    useless_var_1 += obfuscated_hash_function(a1: G3 * G2, a2: useless_var_4);
    useless_var_5 = obfuscated_prng_function(a1: G3 * G2 + G1, a2: dword_555563DF3934) + v41;
    if ( (useless_var_1 & 1) != 0 )
    useless_var_2 += obfuscated_hash_function(a1: v43, a2: G1);
    useless_var_3 += obfuscated_prng_function(a1: v43, a2: G1);
    result = 1;
    if ( G5 != WEIRD_SUM_OF_G1_TO_G4 )
    {
        obfuscated_hash_function(a1: G3, a2: G3);
        return 0;
    }
    return result;
}

i'd inspected this function once before, so i knew my way around. but i had made one critical assumption:
i believed it was complete nonsense designed to distract you (as you can see by my naming: useless_var_1 etc.)
the actual validate was a single line of code near the bottom, and i'd insisted the function returned either a 0 or 1, and that's all that it did.

i more closely investigated in the function, and made a critical observation.

did you notice it?

take a closer look at the "junk" in the above function.

hidden within all the junk, useless assignments, obfuscated functions that do nothing — were multiple GLOBAL STORES.

let's look at that function again, with all the nonsense stripped.

// LEVEL 1 CHECK
__int64 __fastcall validate_key(int G1, int G2, int G3, signed int G4, int G5)
{
    // [COLLAPSED LOCAL DECLARATIONS. PRESS NUMPAD "+" TO EXPAND]
    ...
    g_key_G1 = G1; // GLOBAL STORE #1: g_key_G1 = G1 (read by process_drowning_and_check_g1_g2 = LEVEL 3)
    ...
    g_key_G2_div128_stored = G2 / 128; // GLOBAL STORE #2
    ...
    i = 0;
    v29 = G2 ^ G3 ^ G5 ^ G4;
    obfuscated_hash_function(a1: G1, a2: v29);
    ptr = &GAME_DATA_ARRAY_PLUS_0x30; // GAME_DATA_ARRAY LOOP 1 START: base = unk_5555609803D0 (= unk_5555609803A0 "GAME_DATA_ARRAY" + 0x30). Writes 1000 records of 212 bytes each. Per record: offset 0x30(DWORD -1), offset 0x40(WORD idx^G1), offset 0x34(DWORD idx^G2div128), offset 0xD0(WORD idx^(G3&0x7F)) = field_104
    do
    {
        ptr += 53;
        obfuscated_hash_function(a1: G1, a2: v29);
        *(ptr - 53) = -1;
        *((_WORD *)ptr - 98) = i ^ G1;
        *(ptr - 52) = i ^ v10;
        v31 = i++ ^ G3 & 0x7F;
        *((_WORD *)ptr - 26) = v31; // Writes field_104 (struct offset 0xD0): idx ^ (G3 & 0x7F). Read by after_game_turn
        obfuscated_hash_function(a1: G1, a2: v29);
    }
    while ( i != 1000 );
    ptr_2 = &GAME_DATA_ARRAY_PLUS_0x30; // GAME_DATA_ARRAY LOOP 2 START: Same base. Writes per record: offset 0x30(DWORD -1), offset 0x40(WORD idx^G1), offset 0x34(DWORD idx^G2div128), offset 0xCE(WORD idx^G1) = field_103. Does NOT write field_104.
    ...
    j = 0;
    do
    {
        ptr_2 += 53;
        obfuscated_hash_function(a1: G1, a2: v29);
        v35 = j ^ G1;
        v36 = j++ ^ v10;
        *(ptr_2 - 53) = -1;
        *((_WORD *)ptr_2 - 98) = v35;
        *(ptr_2 - 52) = v36;
        *((_WORD *)ptr_2 - 27) = v35; // Writes field_103 (struct offset 0xCE): idx ^ G1.
        obfuscated_hash_function(a1: G1, a2: v29);
    }
    while ( j != 1000 );
    ...
    g_key_G1_stored = G1; // GLOBAL STORE #3
    ...
    g_key_G3_shifted_stored = v48; // GLOBAL STORE #4
    ...
    g_key_G4_div128_stored = G4 / 128; // GLOBAL STORE #5
    // the actual validate line
    WEIRD_SUM_OF_G1_TO_G4 = (3 * (G4 & 0x7F) + G1 + 52 * v10 + 19 * G2_AND_0x7F + 15 * v48 + 11 * (G3 & 0x7F) + 7 * (G4 / 128)) % 10000;
    obfuscated_hash_function(a1: G3, a2: G4);
    if ( G5 == WEIRD_SUM_OF_G1_TO_G4 )
        g_key_valid_flag = 1; // GLOBAL STORE #6
    obfuscated_hash_function(a1: G2, a2: G4);
    ...
    result = 1;
    if ( G5 != WEIRD_SUM_OF_G1_TO_G4 )
    {
        obfuscated_hash_function(a1: G3, a2: G3);
        return 0;
    }
    return result;
}

this function not only validates your key with a boolean return value (0 = invalid, 1 = valid)

but — hidden among the nonsense useless assignments and useless function calls — the function also sneakily manipulates global state.
not just any global state, though. it stores G1, G2, G3, and G4!

after noting all the global stores, i figured tackling the next level would be easy! just take a look at the cross-references to those global variables, and boom, find the check that destroys all the citadels.

it was not that easy.

the first thing i found was in process_drowning_and_check_g1_g2— a function called once on line 5071 of after_game_turn.
within the function was this diffident snippet of code:

if ( g_turn_process_counter > 45 )
{
    v14 = (g_key_G1 ^ 0x10) + 3 * ((g_key_G1 ^ 0x10) & 0x1F);
    // LEVEL 3 CHECK
    if ( g_key_G2_div128_stored != ((((unsigned __int8)(v14 + 20 * ((v14 - 20) & 7)) ^ 0x10) + (unsigned __int8)(g_key_G1 / 8)) & 0x3F) )
        g_red_screen_corruption_flag = 1;
}

without the variable names, it just looks like... operations. it blends in well.

now, you may be wondering how i got the name g_red_screen_corruption_flag. well, take a look at the one place it's referenced: render_game_frame.

if ( g_red_screen_corruption_flag == 1 )
{
    glClear(a1: 16640);
    draw_colored_rect(left: 0.0, top: 0.0, width: (float)dword_555557E8CB88, height: (float)dword_555557E8CB84, r: 1.0, g: 0.0, b: 0.0, a: 1.0, a9: v283, a10: v284, a11: v281, a12: v282, a13: v285, a14: v286);
}

when i saw this, i was quite confused. prior to this point, even though i knew deep inside that all these checks were deliberate, i hadn't consciously registered the utter sneakiness of the CoE5 devs.
i now knew they've added these devious checks on purpose, trying to troll reverse engineers.
i mean, look at the zaniness of the situation. if i am understanding it correctly, here's what's happening:

  • key checked on game initialization, stored sneakily into g_key_G1 and g_key_G2_div128_stored
  • you start a game with your fake key
  • you play 45 turns in single-player with no issues, thinking everything is perfect
  • something on the map falls in water and drowns, triggering the key check
  • the check fails: framebuffer is cleared and a red rectangle is permanently drawn onto your screen

i, personally, find this quite hysterical.

though this was not the check i was looking for, let's note it down:

Level Function Check Effect if failed
1 validate_key G5 == weighted_sum(G1,G2,G3,G4) % 10000 Can't enter menu
2 find_and_validate_key_2 G2 & 0x7F == hash(G1) "Team 0 wins" upon starting game
3 process_drowning_and_check_g1_g2 G2/128 == f(G1) Red screen corruption after 45 turns
4 ??? ??? ???
5 ??? ??? ???

level 4

after that, i couldn't easily find anything.
and that's unsurprising, as the level 4 check was the stealthiest of all.

lucky you~ you don't have to dig through anything; we'll just skip to the answer.
let's trace through what happens.
when you open the game, validate_key is called, and it stores your G1 and G3 into a global array, as we saw above:

(simplified code)

// LEVEL 1 CHECK
__int64 __fastcall validate_key(int G1, int G2, int G3, signed int G4, int G5) {
    ...
    _DWORD* ptr = &GAME_DATA_ARRAY + 0x30;
    for(int i = 0; i < 1000; i++)
    {
        ptr += 53; // moves right by 212 bytes (1x GameDataRecord struct)
        *((_WORD *)ptr - 26) = i ^ (G3 & 0x7F); // Writes field_104 (struct offset 53*4-26*2+0x30 = 0xD0): i ^ (G3 & 0x7F)
    }
    
    _DWORD* ptr_2 = &GAME_DATA_ARRAY + 0x30;
    for(int j = 0; j < 1000; j++)
    {
        ptr_2 += 53;
        *((_WORD *)ptr_2 - 27) = j ^ G1; // Writes field_103 (struct offset 53*4-27*2+0x30 = 0xCE): j ^ G1
    }
    ... // the other validation stuff
}

it writes 1000 records of index-XORed G1 and G3 keys into field_103 and field_104 for every 212-byte struct GameDataRecord in the GAME_DATA_ARRAY. but it does so in an odd fashion: manual pointer-arithmetic. this blended in with the other heaps of junk code, making it relatively difficult to find.

then, on line 2921 of after_game_turn — squashed between routine game logic about world events — this code sits buried, undiscovered:

// NORMAL GAME LOGIC
debug_log(format: "eviltimes: new brigand lair at %s\n", v473);
...
if ( dword_555558E148F0 > 25 && (int)random(max: 700) <= 3 && word_555558E14804 == 1 && dword_555560FD9AA8 <= 0 )
{
    sub_55555577E150(a1: (char)"Evil Times. There is a strong sense of evil all over the world of Elysium. Murderers, bandits and brigands are thriving.");
    dword_555560FD9AA8 = sub_5555558528A0(a1: 3) + 3;
}
...

if ( (int)random(max: 100) <= 39 ) // 39% chance to trigger
{
    arr_idx = random(max: 100);
    debug_log(format: (char *)"a %d\n", i);
    // LEVEL 4 CHECK
    if ( GAME_DATA_ARRAY[i].field_104 ^ i != (((unsigned __int8)i ^ (unsigned __int8)GAME_DATA_ARRAY[i].field_103) * ((unsigned __int8)i ^ (unsigned __int8)GAME_DATA_ARRAY[i].field_103) + 124) & 0x7F )
    {
        debug_log(format: "AGT fluff\n");
        // Zero out the entire map, causing all citadels to be destroyed
        memset(s: GAME_MAP_TERRAIN_DATA, c: 0, n: 0x3037C00u);
    }
}

// NORMAL GAME LOGIC
if ( dword_555558E148F0 > 36 )
{
    if ( (int)random(max: 100) <= 1 ) {
        ...
        sub_5555555FDFA0(-1, -2, v738, v737, 4, 0, (__int64)"World News", format: "A %s has found and opened an old gateway to Inferno. Elysium risks getting overrun by devils and demons if this gateway isn't closed again.", v604);
        ...
    }
}

every turn, there is a 39% chance that the game reads field_103 and field_104 from those structs, XORs them with the index again to reveal the raw G1 and G3 & 0x7F key segments, and checks it.

as you can see, when it checks the key, if it's not equal...

debug_log(format: "AGT fluff\n");
// Zero out the entire map, causing all citadels to be destroyed
memset(s: GAME_MAP_TERRAIN_DATA, c: 0, n: 0x3037C00u);

yep, bye-bye map!
"oops, looks like i've deleted 50MB of map data" – this careless function

and the devs once again disguised it, calling it "AGT fluff" (i assume, "after_game_turn fluff").

remember that log from before — the one where i said i couldn't find any errors?

cas♥ /home/cas/apps/ConquestOfElysium5/coe5
...
mist: spread from 373,21
mist: spread from 389,21
mist: spread from 388,24
mist: spread from 505,24
mist: spread from 496,26
mist: spread from 154,30
mist: spread from 151,32
a 67
AGT fluff
updatevoid
bt3: Giant Jellyfish dies [unr -1][vic 63]
hit: dead unr63 (Giant Jellyfish)
msg to 24: '1 unit drowned in a Plain.'
bt3: Asp Turtle dies [unr -1][vic 88]
hit: dead unr88 (Asp Turtle)
msg to 24: '1 unit drowned in a Plain.'
bt3: Hippocampus dies [unr -1][vic 118]
...

yep, that exact line "AGT fluff" was in the log, and i just scrolled past it. how inconspicuous!

Level Function Check Effect if failed
1 validate_key G5 == weighted_sum(G1,G2,G3,G4) % 10000 Can't enter menu
2 find_and_validate_key_2 G2 & 0x7F == hash(G1) "Team 0 wins" upon starting game
3 process_drowning_and_check_g1_g2 G2/128 == f(G1) Red screen corruption after 45 turns
4 after_game_turn G3 & 0x7F == ((G1 & 0xFF)² + 124) & 0x7F 50MB map zeroed → all citadels destroyed
5 ??? ??? ???

level 5

so now we have a key that works perfectly — for singleplayer, that is.
if i even tried to connect to my localhost server, it would fail with the blasted UI telling me "Key authentication failed (%d). ...".
to wrap this project up, i would have to crack this check as well.

looking at the xrefs, some lines within the function connect_to_lobby_server referenced g_key_G1, G3, and G4.
after examination, this code was the client sending packet #90 ("LOBBY HELLO"):

debug_log(format: "connect %d errno %d\n", v81, v83);
if ( dword_55556090AFE0 != 0 || (_DWORD)(result = render_game_frame(a1: 0, a2: 0, a3: v99)) != 999 )
{
    debug_log(format: "send hello to gamelobby\n");
    // LOBBY HELLO PACKET: sends protocol version 5599, game version 539, then G1, G3_shifted, G4_div128 to server.
    // Server validates these and responds with packet #91, containing an i16 value: -1=too old, -3=msg, -4=KEY BANNED, positive=success
    tcp_beginpacket();
    tcp_put_u8(a1: 90);                   // packet type 90 - "hello"
    tcp_put_i16(a1: 5599);
    tcp_put_i16(a1: 539);
    tcp_put_i32(a1: g_key_G1);
    tcp_put_i32(a1: g_key_G3_shifted_stored);
    tcp_put_i32(a1: g_key_G4_div128_stored);
    tcp_put_i16(a1: 0);
    sendpacket_wrapper(a1: 0xFFFFFFFFLL);
    netlobbyscreen();
}

but this packet wasn't actually that relevant. i think it's mostly just to not let people join when their game client is outdated. as you can see, it sends 539 aka 5.39, the current version of CoE5.

the real discovery was when i searched for that string "Key authentication failed (%d)":

tcp_beginpacket();
tcp_put_u8(a1: 1);
sendpacket(a1: 0xFFFFFFFFLL, a2: 0);
debug_log(format: "wait for pack 2\n");
sub_5555557006A0(a1: 2, a2: "Waiting for game status");
dword_5555599C9710 = (char)read_1_byte();

tcp_beginpacket();
tcp_put_u8(a1: 29);                           // GAME SERVER AUTH PACKET (type 29): sends G1 and G3_shifted to game server. Server responds with packet 30 containing 2-byte value: <=0=AUTH FAILED, 2=key in use (old user kicked), 1=success
tcp_put_i32(a1: g_key_G1);
tcp_put_i32(a1: g_key_G3_shifted_stored);
sendpacket(a1: 0xFFFFFFFFLL, a2: 0);
debug_log(format: "wait for pack 30\n");
sub_5555557006A0(a1: 30, a2: &byte_5555559607CE);
value = (__int16)read_2_bytes();
if ( value <= 0 )
{
    // THE KEYGEN FAILS THIS CHECK! (the local server has more key-validation math — Level 5)
    showing_some_ui(
        a1: (unsigned int)"Key authentication failed (%d). Make sure no one else is using the same serial key. If you recently disconnected from a server wait a minute before reconnecting.",
        a2: value,
        a3: v157,
        a4: v158,
        a5: v160,
        a6: v161);
    kill_the_game(s: (char *)&byte_5555559607CE);
}

this client code begins by sending packet #1, some kind of empty ping. it then waits for the server to respond with packet #2 "pong", and reads it with read_1_byte.

BORING!

but right after that, we send packet #29 ("KEY AUTH") to the server, and we await the packet #30 response from the server, reading it with value = (__int16)read_2_bytes();

if value is negative, the authentication failed, and boom, that pesky failure UI pops up.

so the most pragmatic approach was to jump straight to the server-side game_server_packet_handler for packet #29.
and here it is, the whole algorithm laid out:

if ( received_pkt_type == 29 )
{
    // LEVEL 5 CHECK: Packet type 29 - Key validation. Reads G1 and G3/128.
    // Response: 0=fail, 1=success, 2=duplicate serial (kick old user). Sends packet #30 with i16 result.
    value = 0;
    G1 = tcp_get_i32();
    G3_shifted = tcp_get_i32();
    if ( G1 > 0 )
    {
        v342 = G1 + 300;
        if ( (G1 & 1) == 0 )
            v342 = G1;
        if ( (v342 & 4) != 0 )
            v342 += 30;
        v343 = v342 >> 1;
        ...
        if ( (v365 & 1) != 0 )
            v365 += 300;
        if ( (v365 & 4) != 0 )
            v365 += 30;
        v366 = v365 ^ v364;
        v367 = v365 >> 1;
        if ( (v367 & 1) != 0 )
            LOBYTE(v367) = v367 + 44;
        if ( (v367 & 4) != 0 )
            LOBYTE(v367) = v367 + 30;
        if ( G3_shifted == ((v366 ^ (unsigned __int8)v367) & 0x3F) )
        {
            v368 = dword_555558E148EC;
            dword_55556193B5C0[v20] = G1;
            if ( v368 < 0 )
            {
                value = 1;
            }
            else
            {
                v369 = 0;
                value = 1;
                do
                {
                      if ( v14 != (_DWORD)v369 && G1 == dword_55556193B5C0[v369] )
                      {
                        value = 2;
                        debug_log(format: "con %d has same serial %d", v369, G1);
                        ((void (__fastcall *)(_QWORD))sub_5555556FE7A0)(a1: (unsigned int)v369);
                        v368 = dword_555558E148EC;
                      }
                      ++v369;
                }
                while ( v368 >= (int)v369 );
            }
        }
    }
    tcp_beginpacket();
    tcp_put_u8(a1: 30);                       // Respond with pkt#30
    tcp_put_i16(a1: value);
    sendpacket_wrapper(a1: v14);
    goto LABEL_58;
}

the server does some sort of XOR-shift-add300-add30 chain on the client's G1 then checks it against their G3_shifted.

and with that, our table of checks is completed!!! (yay!)

Level Function Check Effect if failed
1 validate_key G5 == weighted_sum(G1,G2,G3,G4) % 10000 Can't enter menu
2 find_and_validate_key_2 G2 & 0x7F == hash(G1) Key not saved, asked each time
3 process_drowning_and_check_g1_g2 G2/128 == f(G1) Red screen corruption after 45 turns
4 after_game_turn G3 & 0x7F == ((G1&0xFF)² + 124) & 0x7F 50MB map zeroed → all citadels destroyed
5 game_server_packet_handler G3/128 == level5_hash(G1) & 0x3F Can't join multiplayer

if you want your XXXXXX-XXXX-XXXX-XXXX-XXXX key to unlock all features of CoE5, you need to pass every one of these checks.

it actually works out nicely: G2, G3, and G5 are the dependent variables to your G1 and G4.
what do i mean? well, with all those algorithms combined, you can select ANY G1 and G4, and the math relationships work out so there is exactly ONE valid pair of G2, G3, G5 that satisfies all those checks (per my testing of the finished keygen).

G2 and G3 can be calculated purely from G1:

for (int g2 = 0; g2 < 10000; g2++)
{
    // level 2 & level 3
    if (find_and_validate_key_2(G1, g2) && drowning_check(G1, g2))
    {
        printf("Found valid G2=%04d to G1=%06d\n", g2, G1);
    }
}

for (int g3 = 0; g3 < 10000; g3++)
{
    // level 4 & level 5
    if (AGT_fluff(G1, g3) && server_packet_29_handler(G1, g3))
    {
        printf("Found valid G3=%04d to G1=%06d\n", g3, G1);
    }
}

and G5 is calculated at the very end from the weighted sum of the other four:

for (int g5 = 0; g5 < 10000; g5++)
{
    // level 1
    if (validate_key(G1, G2, G3, G4, g5))
    {
        printf("Found valid G5=%04d\n", g5);
    }
}

outro

whew, that was a lot of decompiled code and algorithms to go over. i hope it was interesting — i tried to make it entertaining, but probably failed miserably.

this was a fun and enlightening 3-day weekend project, i started around 2026-09-05 22:46, and cracked level 5 at 2026-09-07 15:09. one thing i learned was that the CoE5 devs have a great sense of humor.

disclaimer: i would love to share the keygen source code, but it's unfortunately probably illegal.
for that reason, i may also have messed with the constant numbers for the checks in this article, meaning you would have to reverse engineer the game yourself to make a working keygen. hey, that's not a bad excuse to pick up a new skill ;)

tip: if you want to test other keys on your own coe5 binary, move your coe5key out of the game directory, and you'll get the CD Key prompt on startup.

hope you have an amazing day.