if you’re looking to read a good writeup please read CompleteHTTP or compiled-source sheets, i’m proud of those, this was more a venting session than a writeup

oh boy this challenge was borderline child abuse okay so i spent 10 hours on this challenge, of which only 1 was actually solving it the correct way. i think LITERALLY EVERYONE got too locked in to the mindset that they had to break the seed, myself included. if i USED MY FRICKING BRAIN and and remembered that I SWEAT THE FLIPPING GAME i should have been able to solve it earlier. happy thoughts are most certainly NOT going through my brain as i’m writing this writeup, it’s more like a one sided venting session Nonetheless, here we go!

In Minecraft, certain blocks tend to generate in large flat areas, making it extremely easy to notice repeating textures, which throw the player off. As a result, a deterministic rotation is applied to certain textures of faces of certain blocks based on where they are located. This means, that if you determine the orientation of the textures for quite a few blocks, you can reverse engineer and deterministically calculate where they are located in the world.

So, how does this work? This repository (19MisterX98/TextureRotations) serves as the basis of my implementation. Glancing at the README, we can see how we are supposed to use this. However, I don’t feel like writing Java today, so let’s port it to Python.

Because the world is version 1.21.11, we need to use the Vanilla21_1Textures class.

package texture;

public class Vanilla21_1Textures implements TextureProvider {

    @Override
    public int getTexture(int x, int y, int z, int mod) {
        int rand = random(TextureProvider.getCoordinateRandom(x, y, z));
        return Math.abs(rand) % mod;
    }

    int random(long seed) {
        seed = (seed ^ multiplier) & mask;
        //nextlong combine 2
        return (int)((seed * 0xBB20B4600A69L + 0x40942DE6BAL) >>> 16);
    }
}
package texture;

public interface TextureProvider {

    long multiplier = 0x5DEECE66DL;
    long mask = (1L << 48) - 1;

    private static long getCoordRandom(int x, int y, int z) {
        long l = (long)(x * 3129871) ^ (long)z * 116129781L ^ (long)y;
        l = l * l * 42317861L + l * 11L;
        return l;
    }

    static int getCoordinateRandomLegacy(int x, int y, int z) {
        // ...
    }

    static long getCoordinateRandom(int x, int y, int z) {
        return getCoordRandom(x, y, z) >> 16;
    }

    int getTexture(int x, int y, int z, int mod);
}

Directly translating it is a little bit tricky because Java ints and longs have fixed width precision, causing overflow and missing wrapping issues, including problems with Java’s 48 bit wide LCG. Either way, it’s a good idea to verify against a bunch of test cases that everything is in working order.

def rotation(x, y, z):
    l = (x * 3129871) & (2**32 - 1)
    if l >= 1 << 31: l -= 1 << 32
    v = (l & (2**64 - 1)) ^ (z * 116129781 & (2**64 - 1)) ^ (y & (2**64 - 1))
    v = (v * v * 42317861 + v * 11) & (2**64 - 1)
    seed = ((v >> 16) ^ 0x5DEECE66D) & (2**48 - 1)
    seed = (seed * 0x5DEECE66D + 11) & (2**48 - 1)
    return seed >> 46

Now, we need to obtain the rotations of the block top faces. The easiest way to do this is by using Photoshop’s Perspective Crop tool.

Cropping it will give us a flat 2D view. Now, we need to compare it against the raw grass_block_top.png texture. MAKE SURE THAT THE TOP OF YOUR PERSPECTIVE CROP IS NORTH!!! In this case, it is not. In Minecraft, we need to determine which way goes north. Unlike grass, some other blocks will ALWAYS have the same texture orientation, no matter how you place them (unless you use a debug stick). Thankfully, by looking at the orientation of the top texture on the cracked stone bricks, we can determine that the snow covered mountains are to the south of the ruined portal, and the direction to the bottom right corner of the image is north. After making careful observations, we can collect a decent amount of data.

# (rx, ry, rz, rot)
OBSERVATIONS = (
    (-1, 0, -7, 3), (0, 0, -7, 3),
    (-1, 1, -6, 0), (0, 0, -6, 2),
    (-1, 1, -5, 2), (0, 0, -5, 0), (1, 0, -5, 0),
    (-1, 1, -4, 1), (0, 0, -4, 2), (1, 0, -4, 3),
    (0, 0, -3, 0), (1, 0, -3, 0),
    (0, 0, -2, 1), (0, 0, -1, 0), (0, 0, 0, 0),
)

I ran my first version but it was SUPER duper slow on my machine. Part of that is because my Ryzen 5 5500GT is buns, and also because the program is single-threaded. The best solution would be to offload the processing off to my GPU using something like OpenCL bindings, but I was too tired. Instead, I got my teammate to allocate available resources on his server to cracking this challenge. Along the way, I had another epiphany: I assumed that the challenge creator just booted up a random Minecraft world and /locateed the nearest ruined portal, which should have placed the coordinates relatively close to spawn. Given both of these findings, I changed the ordering of the $(x, z)$ coordinate pairs to spiral outwards from $(0, 0)$ and I refactored the solver into a callback and attached it to Python’s multiprocessing implementation. Final code:

from multiprocessing import Pool
from time import perf_counter

# (rx, ry, rz, rot)
OBSERVATIONS = (
    (-1, 0, -7, 3), (0, 0, -7, 3),
    (-1, 1, -6, 0), (0, 0, -6, 2),
    (-1, 1, -5, 2), (0, 0, -5, 0), (1, 0, -5, 0),
    (-1, 1, -4, 1), (0, 0, -4, 2), (1, 0, -4, 3),
    (0, 0, -3, 0), (1, 0, -3, 0),
    (0, 0, -2, 1), (0, 0, -1, 0), (0, 0, 0, 0),
)

# https://github.com/19MisterX98/TextureRotations/blob/master/src/main/java/texture/TextureProvider.java
# https://github.com/19MisterX98/TextureRotations/blob/master/src/main/java/texture/Vanilla21_1Textures.java
def rotation(x, y, z):
    l = (x * 3129871) & (2**32 - 1)
    if l >= 1 << 31: l -= 1 << 32
    v = (l & (2**64 - 1)) ^ (z * 116129781 & (2**64 - 1)) ^ (y & (2**64 - 1))
    v = (v * v * 42317861 + v * 11) & (2**64 - 1)
    seed = ((v >> 16) ^ 0x5DEECE66D) & (2**48 - 1)
    seed = (seed * 0x5DEECE66D + 11) & (2**48 - 1)
    return seed >> 46

def spiral():
    yield 0, 0
    for r in range(1, 5001):
        yield from ((x, -r) for x in range(-r + 1, r + 1))
        yield from ((r, z) for z in range(-r + 1, r + 1))
        yield from ((x, r) for x in range(r - 1, -r - 1, -1))
        yield from ((-r, z) for z in range(r - 1, -r - 1, -1))

def search(position):
    x, z = position
    return [(x, y, z) for y in range(-128, 129) if all(rotation(x + dx, y + dy, z + dz) == rot for dx, dy, dz, rot in OBSERVATIONS)]

if __name__ == "__main__":
    t = perf_counter()
    hits = []
    with Pool() as pool:
        for found in pool.imap_unordered(search, spiral(), chunksize=1):
            for x, y, z in found:
                hits.append((x, y, z))
                print(f"gaslightCTF{{{x + 3},{y - 1},{z + 1}}} elapsed={perf_counter() - t:.3f}s")
    hits.sort(key=lambda p: ((p[0] + 3) ** 2 + (p[2] + 1) ** 2, p))
    print(f"count={len(hits)}")
    print(f"runtime={perf_counter() - t:.3f}s")

We configure Python 3.14 freethreaded and run the script:

44 minutes and 23.062 seconds on all 16 threads is crazy, and GPU would definitely have been faster. Going down the list, trying flags, it was

gaslightCTF{-3,98,726}

okay this challenge was actually really really dumb and minecraft ball knowledge should NOT have been the last challenge. yes, our team would have done worse, but solving this challenge was MUCH less rewarding than solving like thirds SIMPLY BECAUSE OF HOW DUMB IT WAS i would NOT have solved it if admin hadnt told me I was just straight up doing the whole thing wrong. please never rerun this again