Introduction

Free Ski

Difficulty:
Go to the retro store and help Goose Olivia ski down the mountain and collect all five treasure chests to reveal the hidden flag in this classic SkiFree-inspired challenge.

Olivia (aka the Goose of Space Island) is up on the table in the Retro Emporium:

image-20260102075909371
Olivia

Olivia

HONK! Well hello there! Fancy meeting you here in the Dosis Neighborhood.

You know, it’s the strangest thing… I used to just waddle around the Geese Islands going ‘BONK’ all day long. Random noises, no thoughts, just vibes. But then something changed, and now here I am—speaking, thinking, wondering how I even got here!

HONK! You know what happens to geese in a permanent winter? We can’t migrate! And trust me, being stuck in one place forever isn’t natural—even for someone who just discovered they can think and talk. Frosty needs to chill out… wait, that’s exactly the problem!

This game looks simple enough, doesn’t it? Almost too simple. But between you and me… it seems nearly impossible to win fair and square.

My advice? If you ain’t cheatin’, you ain’t tryin’. wink

Now get out there and show that mountain who’s boss!

Olivia gives me the “Free Ski EXE” which is now in my badge:

Free Ski EXE

Free Ski EXE

A PyInstaller-compiled executable containing a SkiFree-inspired skiing game with hidden treasure chests and flag mechanics.

The link downloads FreeSki.exe.

Game

Trying to run the game crashes. If I start it in a terminal I can see the error:

PS Z:\SansHolidayChallenge-2025\freeski > .\FreeSki.exe
pygame 2.6.1 (SDL 2.28.4, Python 3.13.7)
Hello from the pygame community. https://www.pygame.org/contribute.html
Traceback (most recent call last):
  File "FreeSki.py", line 30, in <module>
    pygame.display.set_caption('FreeSki v0.0')
                                           ^^^
FileNotFoundError: No file 'img/skier.png' found in working directory 'Z:\SansHolidayChallenge-2025\freeski'.
[PYI-7956:ERROR] Failed to execute script 'FreeSki' due to unhandled exception!

It announces that it’s created with PyGame version 2.6.1, using Python 3.13.7. Then it crashes because it can’t find an image. If I create a img directory and put a skier.png into it, there’s a new error:

PS Z:\SansHolidayChallenge-2025\freeski > .\FreeSki.exe
pygame 2.6.1 (SDL 2.28.4, Python 3.13.7)
Hello from the pygame community. https://www.pygame.org/contribute.html
Traceback (most recent call last):
  File "FreeSki.py", line 31, in <module>
FileNotFoundError: No file 'img/skier_left.png' found in working directory 'Z:\SansHolidayChallenge-2025\freeski'.
[PYI-9092:ERROR] Failed to execute script 'FreeSki' due to unhandled exception!

Now it’s missing skier_left.png. I’ll generate eight total images, from this image I had Gemini generate:

Now there’s a font error:

PS Z:\SansHolidayChallenge-2025\freeski > .\FreeSki.exe
pygame 2.6.1 (SDL 2.28.4, Python 3.13.7)
Hello from the pygame community. https://www.pygame.org/contribute.html
Traceback (most recent call last):
  File "FreeSki.py", line 41, in <module>
    victoryimage = pygame.transform.scale_by(pygame.image.load('img/victory.png'), 0.7)
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
FileNotFoundError: No file 'fonts/VT323-Regular.ttf' found in working directory 'Z:\SansHolidayChallenge-2025\freeski'.
[PYI-6332:ERROR] Failed to execute script 'FreeSki' due to unhandled exception!

I’ll find it on Google’s font repo and save it where the game is looking. Now I can play the game:

image-20260102101747481

If I crash, it shows the crash icon:

image-20260102102101169

There’s no real point in playing the game. It’s hard and not fun.

Extract Bytecode

Identify PyInstaller

The challenge and the game say the game is built with PyGame. To make a Windows exe from the script, the developer would need to use a tool such as PyInstaller, cx_Freeze, Nuitka, or py2exe.

It’s PyInstaller:

oxdf@hacky$ strings FreeSki.exe | grep -i pyinstaller 
PYINSTALLER_SUPPRESS_SPLASH_SCREEN
PYINSTALLER_RESET_ENVIRONMENT
PYINSTALLER_STRICT_UNPACK_MODE
Could not load PyInstaller's embedded PKG archive from the executable (%s)
Could not side-load PyInstaller's PKG archive from external file (%s)
_pyinstaller_pyz

I can verify that it’s running Python3.13:

oxdf@hacky$ strings FreeSki.exe | grep -i python 
Could not allocate memory for DYLIB_PYTHON structure.
pyi-python-flag
%s\python%d.%d\lib-dynload
Failed to set python home path: %s
Failed to pre-initialize embedded python interpreter!
Failed to set python home path!
Failed to allocate PyConfig structure! Unsupported python version?
Failed to start embedded python interpreter: %s
Failed to start embedded python interpreter!
bpython313.dll
9python313.dll

pyinstxtractor

PyInstaller makes an executable archive. I’ll use pyinstxtractor_ng to pull out the files from within it:

oxdf@hacky$ pyinstxtractor_ng.py FreeSki.exe 
[+] Processing FreeSki.exe
[+] Pyinstaller version: 2.1+
[+] Python version: 3.13
[+] Length of package: 16806404 bytes
[+] Found 98 files in CArchive
[+] Beginning extraction...please standby
[+] Possible entry point: pyiboot01_bootstrap.pyc
[+] Possible entry point: pyi_rth_inspect.pyc
[+] Possible entry point: pyi_rth_pkgres.pyc
[+] Possible entry point: pyi_rth_setuptools.pyc
[+] Possible entry point: pyi_rth_multiprocessing.pyc
[+] Possible entry point: pyi_rth_pkgutil.pyc
[+] Possible entry point: FreeSki.pyc
[+] Found 471 files in PYZ archive
[!] Error: Failed to decrypt & decompress PYZ.pyz_extracted/jaraco.pyc. Extracting as is.
[!] Error: Failed to decrypt & decompress PYZ.pyz_extracted/setuptools/_distutils/compilers.pyc. Extracting as is.
[!] Error: Failed to decrypt & decompress PYZ.pyz_extracted/setuptools/_distutils/compilers/C.pyc. Extracting as is.
[!] Error: Failed to decrypt & decompress PYZ.pyz_extracted/setuptools/_vendor.pyc. Extracting as is.
[!] Error: Failed to decrypt & decompress PYZ.pyz_extracted/setuptools/_vendor/jaraco.pyc. Extracting as is.
[+] Successfully extracted pyinstaller archive: FreeSki.exe

You can now use a python decompiler on the pyc files within the extracted directory

The resulting directory has all the files necessary for the game:

oxdf@hacky$ ls FreeSki.exe_extracted/
_asyncio.pyd      libopusfile-0.dll        pyimod03_ctypes.pyc          SDL2_ttf.dll
base_library.zip  libpng16-16.dll          pyimod04_pywin32.pyc         select.pyd
_bz2.pyd          libssl-3.dll             pyi_rth_inspect.pyc          setuptools
_ctypes.pyd       libtiff-5.dll            pyi_rth_multiprocessing.pyc  _socket.pyd
_decimal.pyd      libwebp-7.dll            pyi_rth_pkgres.pyc           _ssl.pyd
FreeSki.pyc       _lzma.pyd                pyi_rth_pkgutil.pyc          struct.pyc
freetype.dll      _multiprocessing.pyd     pyi_rth_setuptools.pyc       unicodedata.pyd
_hashlib.pyd      _overlapped.pyd          python313.dll                VCRUNTIME140_1.dll
libcrypto-3.dll   portmidi.dll             PYZ.pyz                      VCRUNTIME140.dll
libffi-8.dll      pyexpat.pyd              PYZ.pyz_extracted            _wmi.pyd
libjpeg-9.dll     pygame                   _queue.pyd                   zlib1.dll
libmodplug-1.dll  pyiboot01_bootstrap.pyc  SDL2.dll
libogg-0.dll      pyimod01_archive.pyc     SDL2_image.dll
libopus-0.dll     pyimod02_importers.pyc   SDL2_mixer.dll

There are DLLs used by PyGame, as well as all the pygame package itself. PYZ.pyz is a compressed archive containing bundled Python modules (both standard library and application dependencies). The .pyc files are Python byte-code files. I suspect FreeSki.pyc is the main program:

oxdf@hacky$ file FreeSki.exe_extracted/FreeSki.pyc 
FreeSki.exe_extracted/FreeSki.pyc: Byte-compiled Python module for CPython 3.12 or newer, timestamp-based, .py timestamp: Thu Jan  1 00:00:00 1970 UTC, .py size: 0 bytes

Decompile Fail

Typically at this point I would use Decompyle++ / pycdc to decompile the Python bytecode to Python. Unfortunately, it fails here:

oxdf@hacky$ ./pycdc ~/SansHolidayChallenge-2025/freeski/FreeSki.exe_extracted/FreeSki.pyc
# Source Generated with Decompyle++
# File: FreeSki.pyc (Python 3.13)

Unsupported opcode: MAKE_FUNCTION (122)
import pygame
import enum
import random
import binascii
None()
pygame.font.init()
screen_width = 800
screen_height = 600
framerate_fps = 60
object_horizonal_hitbox = 1.5
object_vertical_hitbox = 0.5
max_speed = 0.4
accelerate_increment = 0.02
decelerate_increment = 0.05
scale_factor = 0.1
pixels_per_meter = 30
skier_vertical_pixel_location = 100
mountain_width = 1000
obstacle_draw_distance = 23
skier_start = 5
grace_period = 10
screen = pygame.display.set_mode((screen_width, screen_height))
clock = pygame.time.Clock()
dt = 0
pygame.key.set_repeat(500, 100)
pygame.display.set_caption('FreeSki v0.0')
skierimage = pygame.transform.scale_by(pygame.image.load('img/skier.png'), scale_factor)
skier_leftimage = pygame.transform.scale_by(pygame.image.load('img/skier_left.png'), scale_factor)
skier_rightimage = pygame.transform.scale_by(pygame.image.load('img/skier_right.png'), scale_factor)
skier_crashimage = pygame.transform.scale_by(pygame.image.load('img/skier_crash.png'), scale_factor)
skier_pizzaimage = pygame.transform.scale_by(pygame.image.load('img/skier_pizza.png'), scale_factor)
treeimage = pygame.transform.scale_by(pygame.image.load('img/tree.png'), scale_factor)
yetiimage = pygame.transform.scale_by(pygame.image.load('img/yeti.png'), scale_factor)
treasureimage = pygame.transform.scale_by(pygame.image.load('img/treasure.png'), scale_factor)
boulderimage = pygame.transform.scale_by(pygame.image.load('img/boulder.png'), scale_factor)
victoryimage = pygame.transform.scale_by(pygame.image.load('img/victory.png'), 0.7)
gamefont = pygame.font.Font('fonts/VT323-Regular.ttf', 24)
text_surface1 = 'Use arrow keys to ski and find the 5 treasures!'(False, pygame.Color, None('blue'))
text_surface2 = "          find all the lost bears. don't drill into a rock. Win game."(False, pygame.Color, None('yellow'))
flagfont = pygame.font.Font('fonts/VT323-Regular.ttf', 32)
flag_text_surface = 'replace me'(False, pygame.Color, None('saddle brown'))
flag_message_text_surface1 = 'You win! Drill Baby is reunited with'(False, pygame.Color, None('yellow'))
flag_message_text_surface2 = 'all its bears. Welcome to Flare-On 12.'(False, pygame.Color, None('yellow'))
# WARNING: Decompyle incomplete

There are some constants defined at the start, and the images are loaded, but then it just stops with a warning.

Solutions Overview

From here, I’ll show several ways to solve:

flowchart TD;
    A[Python\nByteCode]-->B(<a href='#byte-code-analysis'>ByteCode\nAnalysis</a>);
    B-->C(<a href='#solve-script'>Solve Script</a>);
    C-->D[🏁 Flag];
    A-->E("<a href='#via-ai'>Recover Source\nwith Claude (AI)</a>");
    A-->F(<a href='#pylingualio'>Recover Source\nwith PyLingual</a>)
    E-->G[Source];
    F-->G[Source];
    G-->H(Solve Script\n<a href='#quick-solve'>Quick</a> / <a href='#clean'>Clean</a>);
    H-->D;
    G-->I(<a href='#solve-via-game-mod'>Game Mod</a>);
    I-->D;
linkStyle default stroke-width:2px,stroke:#FFFF99,fill:none;

Solve via ByteCode Analysis

Python Bytecode Background

When Python code runs, it doesn’t execute the .py source file directly. Instead, Python first compiles the source into bytecode, a lower-level platform-independent representation that the Python Virtual Machine (PVM) can execute. This bytecode is stored in .pyc files (compiled Python).

Bytecode is generated automatically when:

  • A module is imported for the first time
  • The source file has been modified since the last compilation
  • Python explicitly compiles it (using py_compile or compileall modules)

The .pyc file format has a header followed by the serialized code object:

Offset Size Description
0 2 Magic number (identifies Python version)
2 2 \r\n (detects corruption from text-mode transfers)
4 4 Bit field (flags)
8 4 Timestamp (if timestamp-based) or hash (if hash-based)
12 4 Source file size
16 Marshalled code object

I’ll look at the header in this file (the first row):

oxdf@hacky$ xxd FreeSki.exe_extracted/FreeSki.pyc | head -4
00000000: f30d 0d0a 0000 0000 0000 0000 0000 0000  ................
00000010: e300 0000 0000 0000 0000 0000 000d 0000  ................
00000020: 0000 0000 00f3 860a 0000 9500 5300 5301  ............S.S.
00000030: 4b00 7200 5300 5301 4b01 7201 5300 5301  K.r.S.S.K.r.S.S.

The magic number changes with each Python version, which is why decompilers often fail on newer Python releases. For Python 3.13, the magic number is 0x0df3 (little-endian: f3 0d), followed by 0d 0a (CRLF).

PyInstaller zeros the flags, timestamp, and size fields for reproducible builds. If it included the actual compilation timestamp, the binary would differ on each build even with identical source code.

On the second line the actual data starts.

Byte Code Analysis

Overview

I’ll get a Python REPL with the matching version of Python (3.13) and load it using the marshal module:

oxdf@hacky$ uv run --python 3.13 python 
Python 3.13.7 (main, Sep 18 2025, 19:47:49) [Clang 20.1.4 ] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import marshal
>>> with open('FreeSki.exe_extracted/FreeSki.pyc', 'rb') as f:
...     data = f.read()
...     
>>> code = marshal.loads(data[16:])

Now I can ask it questions. For example, I can list the functions / classes:

>>> for i, const in enumerate(code.co_consts):
...     if hasattr(const, 'co_name'):
...         print(f'{i}: {const.co_name}')
...         
42: SkierStates
44: Skier
46: Obstacles
50: CalculateObstacleProbabilityRanges
51: Mountain
86: ObstacleSet
88: SetFlag
89: main

main is interesting, but so is SetFlag.

I can get the strings:

>>> for i, c in enumerate(code.co_consts):
...         if isinstance(c, str): print(f'{i}: {repr(c)}')
...         
18: 'FreeSki v0.0'
19: 'img/skier.png'
20: 'img/skier_left.png'
21: 'img/skier_right.png'
22: 'img/skier_crash.png'
23: 'img/skier_pizza.png'
24: 'img/tree.png'
25: 'img/yeti.png'
26: 'img/treasure.png'
27: 'img/boulder.png'
28: 'img/victory.png'
30: 'fonts/VT323-Regular.ttf'
32: 'Use arrow keys to ski and find the 5 treasures!'
34: 'blue'
35: "          find all the lost bears. don't drill into a rock. Win game."
36: 'yellow'
38: 'replace me'
39: 'saddle brown'
40: 'You win! Drill Baby is reunited with'
41: 'all its bears. Welcome to Flare-On 12.'
43: 'SkierStates'
45: 'Skier'
47: 'Obstacles'
52: 'Mountain'
53: 'Mount Snow'
58: 'Aspen'
63: 'Whistler'
68: 'Mount Baker'
72: 'Mount Norquay'
77: 'Mount Erciyes'
81: 'Dragonmount'
87: 'ObstacleSet'
90: '__main__'

There are some weird ones suggesting this may be copied or alluding to Flare-On 12 Challenge 1 Drill Baby Drill!.

SetFlag

I’ll grab the SetFlag function object:

>>> SetFlag = [c for c in code.co_consts if hasattr(c, 'co_name') and c.co_name == "SetFlag"][0]

I can dump the function’s constants (co_consts), local variables (co_varnames), and external references (co_names):

>>> SetFlag.co_consts
(None, 0, 8, 255, 'Flag: %s', '', False, 'saddle brown')
>>> SetFlag.co_varnames
('mountain', 'treasure_list', 'product', 'treasure_val', 'decoded', 'i', 'r', 'flag_text')
>>> print('co_argcount:', SetFlag.co_argcount)
co_argcount: 2
>>> SetFlag.co_varnames[:SetFlag.co_argcount]
('mountain', 'treasure_list')
>>> print('Arguments:', SetFlag.co_varnames[:SetFlag.co_argcount])
Arguments: ('mountain', 'treasure_list')
>>> SetFlag.co_names
('random', 'seed', 'range', 'len', 'encoded_flag', 'randint', 'append', 'chr', 'join', 'print', 'flagfont', 'render', 'pygame', 'Color', 'flag_text_surface')

So the function is SetFlag(mountain, treasure_list). The dis module will dump the disassembly:

>>> dis.dis(SetFlag)
302           RESUME                   0

304           LOAD_CONST               1 (0)
              STORE_FAST               2 (product)

305           LOAD_FAST                1 (treasure_list)
              GET_ITER
      L1:     FOR_ITER                11 (to L2)
              STORE_FAST               3 (treasure_val)

306           LOAD_FAST                2 (product)
              LOAD_CONST               2 (8)
              BINARY_OP                3 (<<)
              LOAD_FAST                3 (treasure_val)
              BINARY_OP               12 (^)
              STORE_FAST               2 (product)
              JUMP_BACKWARD           13 (to L1)

305   L2:     END_FOR
              POP_TOP

307           LOAD_GLOBAL              0 (random)
              LOAD_ATTR                2 (seed)
              PUSH_NULL
              LOAD_FAST                2 (product)
              CALL                     1
              POP_TOP

309           BUILD_LIST               0
              STORE_FAST               4 (decoded)

310           LOAD_GLOBAL              5 (range + NULL)
              LOAD_CONST               1 (0)
              LOAD_GLOBAL              7 (len + NULL)
              LOAD_FAST                0 (mountain)
              LOAD_ATTR                8 (encoded_flag)
              CALL                     1
              CALL                     2
              GET_ITER
      L3:     FOR_ITER                68 (to L4)
              STORE_FAST               5 (i)

311           LOAD_GLOBAL              0 (random)
              LOAD_ATTR               10 (randint)
              PUSH_NULL
              LOAD_CONST               1 (0)
              LOAD_CONST               3 (255)
              CALL                     2
              STORE_FAST               6 (r)

312           LOAD_FAST                4 (decoded)
              LOAD_ATTR               13 (append + NULL|self)
              LOAD_GLOBAL             15 (chr + NULL)
              LOAD_FAST                0 (mountain)
              LOAD_ATTR                8 (encoded_flag)
              LOAD_FAST                5 (i)
              BINARY_SUBSCR
              LOAD_FAST                6 (r)
              BINARY_OP               12 (^)
              CALL                     1
              CALL                     1
              POP_TOP
              JUMP_BACKWARD           70 (to L3)

310   L4:     END_FOR
              POP_TOP

314           LOAD_CONST               4 ('Flag: %s')
              LOAD_CONST               5 ('')
              LOAD_ATTR               17 (join + NULL|self)
              LOAD_FAST                4 (decoded)
              CALL                     1
              BINARY_OP                6 (%)
              STORE_FAST               7 (flag_text)

315           LOAD_GLOBAL             19 (print + NULL)
              LOAD_FAST                7 (flag_text)
              CALL                     1
              POP_TOP

316           LOAD_GLOBAL             20 (flagfont)
              LOAD_ATTR               23 (render + NULL|self)
              LOAD_FAST                7 (flag_text)
              LOAD_CONST               6 (False)
              LOAD_GLOBAL             24 (pygame)
              LOAD_ATTR               26 (Color)
              PUSH_NULL
              LOAD_CONST               7 ('saddle brown')
              CALL                     1
              CALL                     3
              STORE_GLOBAL            14 (flag_text_surface)
              RETURN_CONST             0 (None)

The format is “line_number | label | opcode | argument (decoded value)”. Working through this:

  • 304 initializes product to 0

  • 305-306 makes a loop to build the key. It’s looping over the treasure_list variable and using bit shifting operations and xor to combine it into product. It is effectively:

    for treasure_val in treasure_list:
        product = (product << 8) ^ treasure_val
    

    This encodes all 5 treasure positions into a single integer. Collecting treasures in the wrong order or at wrong positions would produce a different value, leading to a different RNG seed and garbage output.

  • 307 seeds the RNG using product

  • 309 initializes an empty list named decoded

  • 310-312 is the decoding loop, which gets a random int between 0 and 255, xors mountain.encoded_flag[i] by that int, makes it a character, and appends it to decoded.

  • 314 makes the string flag_text = 'Flag: %s' % ''.join(decoded)

  • 315 prints it

main

I’ll grab the main function object:

>>> main = [c for c in code.co_consts if hasattr(c, 'co_name') and c.co_name == "main"][0]

It’s metadata gives some clue about what’s going on:

>>> main.co_consts
(None, False, True, 'white', 2, (42, 42), 40, 1, 0, 5, 1.0, 'Use arrow keys to ski and find the 5 treasures!', "Aww shucks, didn't make it. Press any key to try again.", 'Skiing %s    elevation: %.2f, horizonal: %.2f, treasures %d/5', 'blue', (0, 0), 1000)
>>> main.co_varnames
('victory_mode', 'running', 'reset_mode', 'player_started', 'treasures_collected', 'skier', 'mnt', 'obstacles', 'event', 'x', 'y', 'collided_data', 'collided_object', 'collided_row', 'collided_row_offset', 'top_edge', 'left_edge', 'right_edge', 'bottom_edge', 'skier_drawn', 'obstacle_row', 'obstacle_x', 'obstacle', 'obstacle_image', 'info_text', 'dt')
>>> main.co_argcount
0
>>> main.co_names
('screen', 'fill', 'pygame', 'Color', 'Skier', 'screen_width', 'skier_vertical_pixel_location', 'random', 'choice', 'Mountains', 'height', 'skier_start', 'elevation', 'ObstacleSet', 'obstacle_draw_distance', 'event', 'get', 'type', 'QUIT', 'KEYDOWN', 'isReadyForReset', 'key', 'K_w', 'K_UP', 'SlowDown', 'K_s', 'K_DOWN', 'SpeedUp', 'K_a', 'K_LEFT', 'TurnLeft', 'K_d', 'K_RIGHT', 'TurnRight', 'KEYUP', 'Cruise', 'blit', 'victoryimage', 'flag_text_surface', 'get_width', 'screen_height', 'get_height', 'UpdateLocation', 'Update', 'int', 'pixels_per_meter', 'isMoving', 'CollisionDetect', 'Obstacles', 'TREASURE', 'append', 'mountain_width', 'len', 'SetFlag', 'Crash', 'horizonal_location', 'Draw', 'range', 'ObstacleImages', 'name', 'gamefont', 'render', 'display', 'flip', 'clock', 'tick', 'framerate_fps', 'quit')

The function takes no arguments. co_varnames shows game state variables (victory_mode, running, reset_mode), a treasures_collected list, collision-related variables (collided_data, collided_object, collided_row, collided_row_offset), and rendering variables. The co_names shows it uses pygame for input handling, calls SetFlag when appropriate, and references Mountains.

The full disassembly is long (it’s the main game loop), but the critical section is where treasures are collected and SetFlag is called:

>>> dis.dis(main)                                                              
 319            RESUME                   0                                     
                                                                               
 321            LOAD_CONST               1 (False)                             
                STORE_FAST               0 (victory_mode) 
...[snip]...
 378            LOAD_FAST               12 (collided_object)
                LOAD_GLOBAL             96 (Obstacles)                  
                LOAD_ATTR               98 (TREASURE)                          
                COMPARE_OP              88 (bool(==))\

                POP_JUMP_IF_FALSE       68 (to L20)  

 379            LOAD_CONST               0 (None)
                LOAD_FAST               13 (collided_row)
                LOAD_CONST               7 (1)
                BINARY_SUBSCR
                LOAD_FAST               14 (collided_row_offset)
                STORE_SUBSCR

 380            LOAD_FAST_CHECK          4 (treasures_collected)
                LOAD_ATTR              101 (append + NULL|self)
                LOAD_FAST               13 (collided_row)
                LOAD_CONST               8 (0)
                BINARY_SUBSCR
                LOAD_GLOBAL            102 (mountain_width)
                BINARY_OP                5 (*)
                LOAD_FAST               14 (collided_row_offset)
                BINARY_OP                0 (+)
                CALL                     1
                POP_TOP

 381            LOAD_GLOBAL            105 (len + NULL)
                LOAD_FAST                4 (treasures_collected)
                CALL                     1
                LOAD_CONST               9 (5)
                COMPARE_OP              88 (bool(==))
                POP_JUMP_IF_FALSE       14 (to L19)

 382            LOAD_GLOBAL            107 (SetFlag + NULL)
                LOAD_FAST_CHECK          6 (mnt)
                LOAD_FAST                4 (treasures_collected)
                CALL                     2
                POP_TOP

 383            LOAD_CONST               2 (True)
                STORE_FAST               0 (victory_mode)

  --   L19:     JUMP_FORWARD            16 (to L21)

 385   L20:     LOAD_FAST                5 (skier)                             
                LOAD_ATTR              109 (Crash + NULL|self)                 
                CALL                     0
                POP_TOP   
                
 388   L21:     LOAD_FAST                5 (skier) 
...[snip]...

379 - 383 seem to handle collisions:

  • 378 checks that the collided_object is a TREASURE, and if not, it jumps to L20.
  • 379 sets collided_row[1][collided_row_offset] = None, removing the treasure from the map so it can’t be collected twice.
  • 380 calculates the treasure value and appends it to the list: treasures_collected.append(collided_row[0] * mountain_width + collided_row_offset). Here collided_row[0] is the elevation (row number) and collided_row_offset is the horizontal position. With mountain_width = 1000, each treasure becomes a unique value encoding its position.
  • 381 checks if all 5 treasures have been collected. If not, it jumps to L19 which jumps to L21 at 388 breaking out of this part.
  • 382-383 trigger victory, calling SetFlag(mnt, treasures_collected) and victory_mode = True.
  • 385 (at label L20) means that a non-treasure was collided, and calls skier.Crash().

The important part here is understanding the treasures_collected format. Each treasure’s position is encoded as elevation * 1000 + horizontal_offset, creating a unique value. The order matters too - collecting the same treasures in a different sequence would produce a different decryption key. The only unknown part here is mnt, which is passed to SetFlag. That’s set up at 333:

 333            LOAD_GLOBAL             14 (random)
                LOAD_ATTR               16 (choice)
                PUSH_NULL
                LOAD_GLOBAL             18 (Mountains)
                CALL                     1
                STORE_FAST               6 (mnt)

It’s a random choice from Mountains.

Globals

code.co_names gives the global variables:

>>> code.co_names
('pygame', 'enum', 'random', 'binascii', 'init', 'font', 'screen_width', 'screen_height', 'framerate_fps', 'object_horizonal_hitbox', 'object_vertical_hitbox', 'max_speed', 'accelerate_increment', 'decelerate_increment', 'scale_factor', 'pixels_per_meter', 'skier_vertical_pixel_location', 'mountain_width', 'obstacle_draw_distance', 'skier_start', 'grace_period', 'display', 'set_mode', 'screen', 'time', 'Clock', 'clock', 'dt', 'key', 'set_repeat', 'set_caption', 'transform', 'scale_by', 'image', 'load', 'skierimage', 'skier_leftimage', 'skier_rightimage', 'skier_crashimage', 'skier_pizzaimage', 'treeimage', 'yetiimage', 'treasureimage', 'boulderimage', 'victoryimage', 'Font', 'gamefont', 'render', 'Color', 'text_surface1', 'text_surface2', 'flagfont', 'flag_text_surface', 'flag_message_text_surface1', 'flag_message_text_surface2', 'Enum', 'SkierStates', 'CRUISING', 'ACCELERATING', 'DECELERATING', 'TURNING_LEFT', 'TURNING_RIGHT', 'CRASHED', 'SkierStateImages', 'Skier', 'Obstacles', 'BOULDER', 'TREE', 'YETI', 'TREASURE', 'ObstacleImages', 'ObstacleProbabilities', 'fakeObstacleProbabilities', 'CalculateObstacleProbabilityRanges', 'ObstacleProbabilitiesRanges', 'Mountain', 'Mountains', 'list', 'ObstacleSet', 'SetFlag', 'main', '__name__')

To solve, I’ll need to understand two of them. The code outside of functions is available at dis.dis(code). This block sets mountain_width to 1000:

 19           LOAD_CONST              13 (1000)
              STORE_GLOBAL            17 (mountain_width)

Mountains is set a bit later:

186           LOAD_BUILD_CLASS
              PUSH_NULL
              LOAD_CONST              51 (<code object Mountain at 0x7716d325fc30, file "FreeSki.py", line 186>)
              MAKE_FUNCTION
              LOAD_CONST              52 ('Mountain')
              CALL                     2
              STORE_NAME              75 (Mountain)

251           LOAD_NAME               75 (Mountain)
              PUSH_NULL
              LOAD_CONST              53 ('Mount Snow')
              LOAD_CONST              54 (3586)
              LOAD_CONST              55 (3400)
              LOAD_CONST              56 (2400)
              LOAD_CONST              57 (b'\x90\x00\x1d\xbc\x17b\xed6S"\xb0<Y\xd6\xce\x169\xae\xe9|\xe2Gs\xb7\xfdy\xcf5\x98')
              CALL                     5

252           LOAD_NAME               75 (Mountain)
              PUSH_NULL
              LOAD_CONST              58 ('Aspen')
              LOAD_CONST              59 (11211)
              LOAD_CONST              60 (11000)
              LOAD_CONST              61 (10000)
              LOAD_CONST              62 (b'U\xd7%x\xbfvj!\xfe\x9d\xb9\xc2\xd1k\x02y\x17\x9dK\x98\xf1\x92\x0f!\xf1\\\xa0\x1b\x0f')
              CALL                     5

253           LOAD_NAME               75 (Mountain)
              PUSH_NULL
              LOAD_CONST              63 ('Whistler')
              LOAD_CONST              64 (7156)
              LOAD_CONST              65 (6000)
              LOAD_CONST              66 (6500)
              LOAD_CONST              67 (b'\x1cN\x13\x1a\x97\xd4\xb2!\xf9\xf6\xd4#\xee\xebh\xecs.\x08M!hr9?\xde\x0c\x86\x02')
              CALL                     5

254           LOAD_NAME               75 (Mountain)
              PUSH_NULL
              LOAD_CONST              68 ('Mount Baker')
              LOAD_CONST              69 (10781)
              LOAD_CONST              70 (9000)
              LOAD_CONST              65 (6000)
              LOAD_CONST              71 (b'\xac\xf9#\xf4T\xf1%h\xbe3FI+h\r\x01V\xee\xc2C\x13\xf3\x97ef\xac\xe3z\x96')
              CALL                     5

255           LOAD_NAME               75 (Mountain)
              PUSH_NULL
              LOAD_CONST              72 ('Mount Norquay')
              LOAD_CONST              73 (6998)
              LOAD_CONST              74 (6300)
              LOAD_CONST              75 (3000)
              LOAD_CONST              76 (b'\x0c\x1c\xad!\xc6,\xec0\x0b+"\x9f@.\xc8\x13\xadb\x86\xea{\xfeS\xe0S\x85\x90\x03q')
              CALL                     5

256           LOAD_NAME               75 (Mountain)
              PUSH_NULL
              LOAD_CONST              77 ('Mount Erciyes')
              LOAD_CONST              78 (12848)
              LOAD_CONST              61 (10000)
              LOAD_CONST              79 (12000)
              LOAD_CONST              80 (b'n\xad\xb4l^I\xdb\xe1\xd0\x7f\x92\x92\x96\x1bq\xca`PvWg\x85\xb21^\x93F\x1a\xee')
              CALL                     5

257           LOAD_NAME               75 (Mountain)
              PUSH_NULL
              LOAD_CONST              81 ('Dragonmount')
              LOAD_CONST              82 (16282)
              LOAD_CONST              83 (15500)
              LOAD_CONST              84 (16000)
              LOAD_CONST              85 (b'Z\xf9\xdf\x7f_\x02\xd8\x89\x12\xd2\x11p\xb6\x96\x19\x05x))v\xc3\xecv\xf4\xe2\\\x9a\xbe\xb5')
              CALL                     5

250           BUILD_LIST               7
              STORE_GLOBAL            76 (Mountains)

Mountains is a list of Mountain objects which I see initialized here. I’ll dig into the Mountain class:

>>> Mountain = [c for c in code.co_consts if hasattr(c, 'co_name') and c.co_name == 'Mountain'][0]
>>> Mountain.co_consts
('Mountain', 186, <code object __init__ at 0x7716d32aeab0, file "FreeSki.py", line 187>, <code object GetObstacles at 0x30dc5d30, file "FreeSki.py", line 195>, <code object GetTreasureLocations at 0x30da86c0, file "FreeSki.py", line 236>, ('encoded_flag', 'height', 'name', 'treasures', 'treeline', 'yetiline'), None)

It has three methods above at offsets 2, 3, and 4. I need the __init__ function, so I’ll grab it and get its arguments:

>>> Mountain_init = Mountain.co_consts[2]
>>> Mountain_init.co_consts
(None,)
>>> Mountain_init.co_argcount
6
>>> Mountain_init.co_varnames[:Mountain_init.co_argcount]
('self', 'name', 'height', 'treeline', 'yetiline', 'encoded_flag')

I’ll also need to understand the GetTreasureLocations function:

>>> Mountain_GetTreasureLocations = Mountain.co_consts[4]
>>> Mountain_GetTreasureLocations.co_varnames[:Mountain_GetTreasureLocations.co_argcount]
('self',)

The code isn’t super long:

>>> dis.dis(Mountain_GetTreasureLocations)
236           RESUME                   0

237           BUILD_MAP                0
              STORE_FAST               1 (locations)

238           LOAD_GLOBAL              0 (random)
              LOAD_ATTR                2 (seed)
              PUSH_NULL
              LOAD_GLOBAL              4 (binascii)
              LOAD_ATTR                6 (crc32)
              PUSH_NULL
              LOAD_FAST                0 (self)
              LOAD_ATTR                8 (name)
              LOAD_ATTR               11 (encode + NULL|self)
              LOAD_CONST               1 ('utf-8')
              CALL                     1
              CALL                     1
              CALL                     1
              POP_TOP

239           LOAD_FAST                0 (self)
              LOAD_ATTR               12 (height)
              STORE_FAST               2 (prev_height)

240           LOAD_CONST               2 (0)
              STORE_FAST               3 (prev_horiz)

241           LOAD_GLOBAL             15 (range + NULL)
              LOAD_CONST               2 (0)
              LOAD_CONST               3 (5)
              CALL                     2
              GET_ITER
      L1:     FOR_ITER                93 (to L2)
              STORE_FAST               4 (i)

243           LOAD_GLOBAL              0 (random)
              LOAD_ATTR               16 (randint)
              PUSH_NULL
              LOAD_CONST               4 (200)
              LOAD_CONST               5 (800)
              CALL                     2
              STORE_FAST               5 (e_delta)

244           LOAD_GLOBAL              0 (random)
              LOAD_ATTR               16 (randint)
              PUSH_NULL
              LOAD_GLOBAL             19 (int + NULL)
              LOAD_CONST               2 (0)
              LOAD_FAST                5 (e_delta)
              LOAD_CONST               6 (4)
              BINARY_OP               11 (/)
              BINARY_OP               10 (-)
              CALL                     1
              LOAD_GLOBAL             19 (int + NULL)
              LOAD_FAST                5 (e_delta)
              LOAD_CONST               6 (4)
              BINARY_OP               11 (/)
              CALL                     1
              CALL                     2
              STORE_FAST               6 (h_delta)

245           LOAD_FAST_LOAD_FAST     54 (prev_horiz, h_delta)
              BINARY_OP                0 (+)
              LOAD_FAST_LOAD_FAST     18 (locations, prev_height)
              LOAD_FAST                5 (e_delta)
              BINARY_OP               10 (-)
              STORE_SUBSCR

246           LOAD_FAST_LOAD_FAST     37 (prev_height, e_delta)
              BINARY_OP               10 (-)
              STORE_FAST               2 (prev_height)

247           LOAD_FAST_LOAD_FAST     54 (prev_horiz, h_delta)
              BINARY_OP                0 (+)
              STORE_FAST               3 (prev_horiz)
              JUMP_BACKWARD           95 (to L1)

241   L2:     END_FOR
              POP_TOP

248           LOAD_FAST                1 (locations)
              RETURN_VALUE

This code does the following:

  • 237 initializes locations as an empty dictionary.
  • 238 seeds the RNG with the CRC32 of the mountain name.
  • 239 loads the starting height into prev_height.
  • 240 sets prev_horiz to 0.
  • 241 sets up a loop for five iterations.
  • 243 sets e_delta to a random int between 200 and 800.
  • 244 sets h_delta to a random int between -e_delta/4 and e_delta/4.
  • 245 sets locations[prev_height - e_delta] = prev_horiz + h_delta
  • 246 and 247 update prev_height and prev_horiz.
  • 241 loops back to the start of the loop.
  • 248 returns locations.

Solve Script

To summarize what I’ve learned: the flag is XOR-decrypted using random bytes seeded by a value derived from the five treasure positions. Each mountain has deterministic treasure locations based on its name (via CRC32 seeding). I need to calculate those positions in the correct order and derive the decryption seed.

For each mountain (or really any one mountain), I’ll need to calculate its list of treasure locations and use those to get the flag:

import random
import binascii

def get_treasure_locations(mountain):
    name, height, _ = mountain
    mountain_width = 1000

    random.seed(binascii.crc32(name.encode('utf-8')))

    locations = {}
    prev_height = height
    prev_horiz = 0

    for i in range(5):
        e_delta = random.randint(200, 800)
        h_delta = random.randint(int(0 - e_delta / 4), int(e_delta / 4))
        locations[prev_height - e_delta] = prev_horiz + h_delta
        prev_height = prev_height - e_delta
        prev_horiz = prev_horiz + h_delta

    return [elev * mountain_width + (locations[elev] % mountain_width)
            for elev in sorted(locations.keys(), reverse=True)]

def get_flag(encrypted_flag, treasure_list):
    product = 0
    for treasure_val in treasure_list:
        product = (product << 8) ^ treasure_val

    random.seed(product)
    decoded = []
    for i in range(len(encrypted_flag)):
        r = random.randint(0, 255)
        decoded.append(chr(encrypted_flag[i] ^ r))

    return ''.join(decoded)

Mountains = [
    # name, height, encrypted_flag
    ("Mount Snow", 3586, b'\x90\x00\x1d\xbc\x17b\xed6S"\xb0<Y\xd6\xce\x169\xae\xe9|\xe2Gs\xb7\xfdy\xcf5\x98'),
    ("Aspen", 11211, b'U\xd7%x\xbfvj!\xfe\x9d\xb9\xc2\xd1k\x02y\x17\x9dK\x98\xf1\x92\x0f!\xf1\\\xa0\x1b\x0f'),
    ("Whistler", 7156, b'\x1cN\x13\x1a\x97\xd4\xb2!\xf9\xf6\xd4#\xee\xebh\xecs.\x08M!hr9?\xde\x0c\x86\x02'),
    ("Mount Baker", 10781, b'\xac\xf9#\xf4T\xf1%h\xbe3FI+h\r\x01V\xee\xc2C\x13\xf3\x97ef\xac\xe3z\x96'),
    ("Mount Norquay", 6998, b'\x0c\x1c\xad!\xc6,\xec0\x0b+"\x9f@.\xc8\x13\xadb\x86\xea{\xfeS\xe0S\x85\x90\x03q'),
    ("Mount Erciyes", 12848, b'n\xad\xb4l^I\xdb\xe1\xd0\x7f\x92\x92\x96\x1bq\xca`PvWg\x85\xb21^\x93F\x1a\xee'),
    ("Dragonmount", 16282, b'Z\xf9\xdf\x7f_\x02\xd8\x89\x12\xd2\x11p\xb6\x96\x19\x05x))v\xc3\xecv\xf4\xe2\\\x9a\xbe\xb5'),
]

for mount in Mountains:
    treasures = get_treasure_locations(mount)
    flag = get_flag(mount[2], treasures)
    print(f"{mount[0]}: {flag}")

I’ll save the necessary data for each mountain (name, height, and encrypted_flag) in a tuple, and loop over it. get_treasure_locations generates that list using the logic above. get_flag will then decrypt the flag using those locations.

It works:

oxdf@hacky$ uv run ./solve_bytes.py
Mount Snow: frosty_yet_predictably_random
Aspen: frosty_yet_predictably_random
Whistler: frosty_yet_predictably_random
Mount Baker: frosty_yet_predictably_random
Mount Norquay: frosty_yet_predictably_random
Mount Erciyes: frosty_yet_predictably_random
Dragonmount: frosty_yet_predictably_random

Solve via Decompilation

Recover Source

Via AI

I gave the byte-code to Claude and let it try to generate the Python source. It did a pretty good job. When asked how it did it:

> so how did you make FreeSki.py? 

● I manually wrote it by reading the dis.dis() output and translating it to Python by hand. No script.

  The process was:
  1. Run dis.dis(code) for each function
  2. Read the bytecode instructions
  3. Write the equivalent Python code

  For example, when I see:
  LOAD_FAST    product
  LOAD_CONST   8
  BINARY_OP    << 
  LOAD_FAST    treasure_val
  BINARY_OP    ^
  STORE_FAST   product

  I write:
  product = (product << 8) ^ treasure_val

  It's tedious but straightforward. The co_varnames, co_names, and co_consts tell you all the variable/function names and constants. The bytecode tells you how they're combined.

I had to tell it to use uv run --python 3.13 to get the right version of Python, but otherwise it was pretty much on its own. The result is:

import pygame
import enum
import random
import binascii

pygame.init()
pygame.font.init()

screen_width = 800
screen_height = 600
framerate_fps = 60
object_horizonal_hitbox = 1.5
object_vertical_hitbox = 0.5
max_speed = 0.4
accelerate_increment = 0.02
decelerate_increment = 0.05
scale_factor = 0.1
pixels_per_meter = 30
skier_vertical_pixel_location = 100
mountain_width = 1000
obstacle_draw_distance = 23
skier_start = 5
grace_period = 10

screen = pygame.display.set_mode((screen_width, screen_height))
clock = pygame.time.Clock()
dt = 0

pygame.key.set_repeat(500, 100)
pygame.display.set_caption('FreeSki v0.0')

skierimage = pygame.transform.scale_by(pygame.image.load('img/skier.png'), scale_factor)
skier_leftimage = pygame.transform.scale_by(pygame.image.load('img/skier_left.png'), scale_factor)
skier_rightimage = pygame.transform.scale_by(pygame.image.load('img/skier_right.png'), scale_factor)
skier_crashimage = pygame.transform.scale_by(pygame.image.load('img/skier_crash.png'), scale_factor)
skier_pizzaimage = pygame.transform.scale_by(pygame.image.load('img/skier_pizza.png'), scale_factor)
treeimage = pygame.transform.scale_by(pygame.image.load('img/tree.png'), scale_factor)
yetiimage = pygame.transform.scale_by(pygame.image.load('img/yeti.png'), scale_factor)
treasureimage = pygame.transform.scale_by(pygame.image.load('img/treasure.png'), scale_factor)
boulderimage = pygame.transform.scale_by(pygame.image.load('img/boulder.png'), scale_factor)
victoryimage = pygame.transform.scale_by(pygame.image.load('img/victory.png'), 0.7)

gamefont = pygame.font.Font('fonts/VT323-Regular.ttf', 24)
text_surface1 = gamefont.render('Use arrow keys to ski and find the 5 treasures!', False, pygame.Color('blue'))
text_surface2 = gamefont.render("          find all the lost bears. don't drill into a rock. Win game.", False, pygame.Color('yellow'))
flagfont = pygame.font.Font('fonts/VT323-Regular.ttf', 32)
flag_text_surface = flagfont.render('replace me', False, pygame.Color('saddle brown'))
flag_message_text_surface1 = flagfont.render('You win! Drill Baby is reunited with', False, pygame.Color('saddle brown'))
flag_message_text_surface2 = flagfont.render('all its bears. Welcome to Flare-On 12.', False, pygame.Color('saddle brown'))


class SkierStates(enum.Enum):
    CRUISING = enum.auto()
    ACCELERATING = enum.auto()
    DECELERATING = enum.auto()
    TURNING_LEFT = enum.auto()
    TURNING_RIGHT = enum.auto()
    CRASHED = enum.auto()


SkierStateImages = {
    SkierStates.CRUISING: skierimage,
    SkierStates.ACCELERATING: skierimage,
    SkierStates.DECELERATING: skier_pizzaimage,
    SkierStates.TURNING_LEFT: skier_leftimage,
    SkierStates.TURNING_RIGHT: skier_rightimage,
    SkierStates.CRASHED: skier_crashimage,
}


class Skier:
    """X and Y denote the pixel coordinates of the bottom center of the skier image"""

    def __init__(self, x, y):
        self.state = SkierStates.CRUISING
        self.elevation = 0
        self.horizonal_location = 0
        self.speed = 0
        self.skierimage = skierimage
        imagerect = self.skierimage.get_rect()
        self.rect = pygame.Rect(x - imagerect.left, y - imagerect.height, imagerect.left, imagerect.height)

    def Draw(self, surface):
        surface.blit(SkierStateImages[self.state], self.rect)

    def TurnLeft(self):
        self.StateChange(SkierStates.TURNING_LEFT)

    def TurnRight(self):
        self.StateChange(SkierStates.TURNING_RIGHT)

    def SlowDown(self):
        if self.speed > decelerate_increment:
            self.speed -= decelerate_increment
        self.StateChange(SkierStates.DECELERATING)

    def SpeedUp(self):
        if self.speed < max_speed - accelerate_increment:
            self.speed += accelerate_increment
        self.StateChange(SkierStates.ACCELERATING)

    def Cruise(self):
        self.StateChange(SkierStates.CRUISING)

    def StateChange(self, new_state):
        if self.state != SkierStates.CRASHED:
            self.state = new_state

    def UpdateLocation(self):
        """update elevation and horizonal location based on one frame of the current speed and turning status
        speed will be split between down and to the turning side with simplified math to avoid calculating
        square roots"""
        if self.state == SkierStates.TURNING_LEFT:
            self.elevation -= self.speed * 0.7
            self.horizonal_location -= self.speed * 0.3
        elif self.state == SkierStates.TURNING_RIGHT:
            self.elevation -= self.speed * 0.7
            self.horizonal_location += self.speed * 0.3
        else:
            self.elevation -= self.speed

    def isMoving(self):
        return self.state != SkierStates.CRASHED and self.speed > 0

    def Crash(self):
        self.state = SkierStates.CRASHED
        self.speed = 0

    def Reset(self):
        self.state = SkierStates.CRUISING
        self.speed = 0

    def isReadyForReset(self):
        return self.state == SkierStates.CRASHED


class Obstacles(enum.Enum):
    BOULDER = enum.auto()
    TREE = enum.auto()
    YETI = enum.auto()
    TREASURE = enum.auto()


ObstacleImages = {
    Obstacles.BOULDER: boulderimage,
    Obstacles.TREE: treeimage,
    Obstacles.YETI: yetiimage,
    Obstacles.TREASURE: treasureimage,
}

ObstacleProbabilities = {
    Obstacles.BOULDER: 0.005,
    Obstacles.TREE: 0.01,
    Obstacles.YETI: 0.005,
}

fakeObstacleProbabilities = {
    Obstacles.BOULDER: 0.005,
    Obstacles.TREE: 0.01,
}


def CalculateObstacleProbabilityRanges(probabilities):
    remaining = 1.0
    range_dict = {}
    for obstacle, prob in probabilities.items():
        range_dict[obstacle] = (remaining - prob, remaining)
        remaining -= prob
    return range_dict


ObstacleProbabilitiesRanges = CalculateObstacleProbabilityRanges(ObstacleProbabilities)


class Mountain:
    def __init__(self, name, height, treeline, yetiline, encoded_flag):
        self.name = name
        self.height = height
        self.treeline = treeline
        self.yetiline = yetiline
        self.encoded_flag = encoded_flag
        self.treasures = self.GetTreasureLocations()

    def GetObstacles(self, start_elevation, end_elevation):
        obstacles = [None] * mountain_width
        random.seed(binascii.crc32(self.name.encode('utf-8')) + start_elevation)

        # Get treasure locations for this elevation range
        treasure_row = None
        treasure_h = None
        for elev, horiz in self.treasures.items():
            if start_elevation >= elev > end_elevation:
                treasure_row = elev
                treasure_h = horiz
                break

        for i in range(0, mountain_width):
            if treasure_h is not None and i >= treasure_h and i < treasure_h + 3:
                if start_elevation > grace_period:
                    obstacles[(treasure_h + i) % mountain_width] = Obstacles.TREASURE
            else:
                r = random.random()
                for obstacle, (low, high) in ObstacleProbabilitiesRanges.items():
                    if obstacle == Obstacles.TREE and start_elevation > self.treeline:
                        continue
                    if obstacle == Obstacles.YETI and start_elevation > self.yetiline:
                        continue
                    if low <= r < high:
                        obstacles[i] = obstacle
                        break

        return (start_elevation, obstacles)

    def GetTreasureLocations(self):
        locations = {}
        random.seed(binascii.crc32(self.name.encode('utf-8')))
        prev_height = self.height
        prev_horiz = 0
        for i in range(0, 5):
            e_delta = random.randint(200, 800)
            h_delta = random.randint(int(0 - e_delta / 4), int(e_delta / 4))
            locations[prev_height - e_delta] = prev_horiz + h_delta
            prev_height = prev_height - e_delta
            prev_horiz = prev_horiz + h_delta
        return locations


Mountains = [
    Mountain('Mount Snow', 3586, 3400, 2400, b'\x90\x00\x1d\xbc\x17b\xed6S"\xb0<Y\xd6\xce\x169\xae\xe9|\xe2Gs\xb7\xfdy\xcf5\x98'),
    Mountain('Aspen', 11211, 11000, 10000, b'U\xd7%x\xbfvj!\xfe\x9d\xb9\xc2\xd1k\x02y\x17\x9dK\x98\xf1\x92\x0f!\xf1\\\xa0\x1b\x0f'),
    Mountain('Whistler', 7156, 6000, 6500, b'\x1cN\x13\x1a\x97\xd4\xb2!\xf9\xf6\xd4#\xee\xebh\xecs.\x08M!hr9?\xde\x0c\x86\x02'),
    Mountain('Mount Baker', 10781, 9000, 9000, b'\xac\xf9#\xf4T\xf1%h\xbe3FI+h\r\x01V\xee\xc2C\x13\xf3\x97ef\xac\xe3z\x96'),
    Mountain('Mount Norquay', 6998, 6300, 3000, b'\x0c\x1c\xad!\xc6,\xec0\x0b+"\x9f@.\xc8\x13\xadb\x86\xea{\xfeS\xe0S\x85\x90\x03q'),
    Mountain('Mount Erciyes', 12848, 12000, 12000, b'n\xad\xb4l^I\xdb\xe1\xd0\x7f\x92\x92\x96\x1bq\xca`PvWg\x85\xb21^\x93F\x1a\xee'),
    Mountain('Dragonmount', 16282, 15500, 16000, b'Z\xf9\xdf\x7f_\x02\xd8\x89\x12\xd2\x11p\xb6\x96\x19\x05x))v\xc3\xecv\xf4\xe2\\\x9a\xbe\xb5'),
]


class ObstacleSet(list):
    def __init__(self, mountain, max_elevation, max_distance):
        super().__init__()
        self.mountain = mountain
        for i in range(max_distance):
            self.append(mountain.GetObstacles(max_elevation - i, max_elevation - i - 1))

    def Update(self, new_max_elevation):
        while len(self) > 0 and self[0][0] > new_max_elevation:
            self.pop(0)
        if len(self) > 0:
            min_elevation = self[-1][0]
            while min_elevation > new_max_elevation - obstacle_draw_distance:
                min_elevation -= 1
                self.append(self.mountain.GetObstacles(min_elevation, min_elevation - 1))

    def CollisionDetect(self, skier):
        for row in self:
            if row[0] > skier.elevation:
                continue
            if row[0] < skier.elevation - object_vertical_hitbox:
                return None
            hitrange_left = skier.horizonal_location - object_horizonal_hitbox
            hitrange_right = skier.horizonal_location + object_horizonal_hitbox
            for x in range(int(hitrange_left), int(hitrange_right) + 1):
                obj_at_loc = row[1][x % mountain_width]
                if obj_at_loc is not None:
                    if hitrange_left <= x <= hitrange_right:
                        return (obj_at_loc, row, x % mountain_width)
        return None


def SetFlag(mountain, treasure_list):
    product = 0
    for treasure_val in treasure_list:
        product = (product << 8) ^ treasure_val
    random.seed(product)
    decoded = []
    for i in range(len(mountain.encoded_flag)):
        r = random.randint(0, 255)
        decoded.append(chr(mountain.encoded_flag[i] ^ r))
    flag_text = 'Flag: %s' % ''.join(decoded)
    print(flag_text)
    global flag_text_surface
    flag_text_surface = flagfont.render(flag_text, False, pygame.Color('saddle brown'))


def main():
    victory_mode = False
    running = True
    reset_mode = True

    while running:
        screen.fill(pygame.Color('white'))

        if reset_mode:
            player_started = False
            treasures_collected = []
            skier = Skier(screen_width / 2, skier_vertical_pixel_location)
            mnt = random.choice(Mountains)
            skier.elevation = mnt.height - skier_start
            obstacles = ObstacleSet(mnt, mnt.height - skier_start, obstacle_draw_distance)
            reset_mode = False

        for event in pygame.event.get():
            if event.type == pygame.QUIT:
                running = False
            if event.type == pygame.KEYDOWN:
                if skier.isReadyForReset():
                    reset_mode = True
                    continue
                player_started = True
                if event.key == pygame.K_w or event.key == pygame.K_UP:
                    skier.SlowDown()
                elif event.key == pygame.K_s or event.key == pygame.K_DOWN:
                    skier.SpeedUp()
                elif event.key == pygame.K_a or event.key == pygame.K_LEFT:
                    skier.TurnLeft()
                elif event.key == pygame.K_d or event.key == pygame.K_RIGHT:
                    skier.TurnRight()
            elif event.type == pygame.KEYUP:
                skier.Cruise()

        if victory_mode:
            screen.blit(victoryimage, (42, 42))
            x = screen_width / 2 - flag_text_surface.get_width() / 2
            y = screen_height / 2 - flag_text_surface.get_height() / 2 + 40
            screen.blit(flag_text_surface, (x, y))
        else:
            skier.UpdateLocation()
            obstacles.Update(int(skier.elevation + skier_vertical_pixel_location / pixels_per_meter))

            if skier.isMoving():
                collided_data = obstacles.CollisionDetect(skier)
                if collided_data is not None:
                    collided_object, collided_row, collided_row_offset = collided_data
                    if collided_object == Obstacles.TREASURE:
                        collided_row[1][collided_row_offset] = None
                        treasures_collected.append(collided_row[0] * mountain_width + collided_row_offset)
                        if len(treasures_collected) == 5:
                            SetFlag(mnt, treasures_collected)
                            victory_mode = True
                    else:
                        skier.Crash()

            top_edge = skier.elevation + skier_vertical_pixel_location / pixels_per_meter
            left_edge = skier.horizonal_location - screen_width / 2 / pixels_per_meter
            right_edge = skier.horizonal_location + screen_width / 2 / pixels_per_meter
            right_edge += 2
            bottom_edge = skier.elevation + screen_height / pixels_per_meter

            skier_drawn = False
            for obstacle_row in obstacles:
                if not skier_drawn and obstacle_row[0] + 1 < skier.elevation:
                    skier.Draw(screen)
                    skier_drawn = True
                for obstacle_x in range(int(left_edge) - 1, int(right_edge) + 1):
                    obstacle = obstacle_row[1][obstacle_x % mountain_width]
                    if not obstacle:
                        continue
                    obstacle_image = ObstacleImages[obstacle]
                    x = obstacle_x * pixels_per_meter * 1.0 - left_edge * pixels_per_meter
                    y = top_edge * pixels_per_meter - obstacle_row[0] * pixels_per_meter * 1.0
                    y -= obstacle_image.get_height()
                    screen.blit(obstacle_image, (x, y))

            if not skier_drawn:
                skier.Draw(screen)

            if skier.isReadyForReset():
                screen.blit(text_surface1, (42, 42))
                info_text = "Aww shucks, didn't make it. Press any key to try again."
            else:
                info_text = 'Skiing %s    elevation: %.2f, horizonal: %.2f, treasures %d/5' % (
                    mnt.name, skier.elevation, skier.horizonal_location, len(treasures_collected))

            info_surface = gamefont.render(info_text, False, pygame.Color('blue'))
            screen.blit(info_surface, (0, 0))

        pygame.display.flip()
        clock.tick(framerate_fps)

    pygame.quit()


if __name__ == '__main__':
    main()

PyLingual.io

In a chat about this challenge on Discord, i81b4u mentioned PyLingual.io, which is a relatively new project out of UT Dallas that:

PyLingual is a Python decompilation service that restores Python bytecode back to the Python source code. PyLingual makes use of transformer models to learn new Python bytecode specifications as they are released. PyLingual is the first Python decompiler to verify the results of decompilation and localize semantic errors. With this and other localized error detection, we can provide a simple method for users to correct or patch decompiled source code and compare it with the original PYC file. For more details, check out our GitHub repository or our research paper.

This approach did report a couple errors:

image-20260102115321778

But it recovered what looks like a complete Python script:

# Decompiled with PyLingual (https://pylingual.io)
# Internal filename: FreeSki.py
# Bytecode version: 3.13.0rc3 (3571)
# Source timestamp: 1970-01-01 00:00:00 UTC (0)

global Mountains
global mountain_width
global grace_period
global flag_text_surface
import pygame
import enum
import random
import binascii
pygame.init()
pygame.font.init()
screen_width = 800
screen_height = 600
framerate_fps = 60
object_horizonal_hitbox = 1.5
object_vertical_hitbox = 0.5
max_speed = 0.4
accelerate_increment = 0.02
decelerate_increment = 0.05
scale_factor = 0.1
pixels_per_meter = 30
skier_vertical_pixel_location = 100
mountain_width = 1000
obstacle_draw_distance = 23
skier_start = 5
grace_period = 10
screen = pygame.display.set_mode((screen_width, screen_height))
clock = pygame.time.Clock()
dt = 0
pygame.key.set_repeat(500, 100)
pygame.display.set_caption('FreeSki v0.0')
skierimage = pygame.transform.scale_by(pygame.image.load('img/skier.png'), scale_factor)
skier_leftimage = pygame.transform.scale_by(pygame.image.load('img/skier_left.png'), scale_factor)
skier_rightimage = pygame.transform.scale_by(pygame.image.load('img/skier_right.png'), scale_factor)
skier_crashimage = pygame.transform.scale_by(pygame.image.load('img/skier_crash.png'), scale_factor)
skier_pizzaimage = pygame.transform.scale_by(pygame.image.load('img/skier_pizza.png'), scale_factor)
treeimage = pygame.transform.scale_by(pygame.image.load('img/tree.png'), scale_factor)
yetiimage = pygame.transform.scale_by(pygame.image.load('img/yeti.png'), scale_factor)
treasureimage = pygame.transform.scale_by(pygame.image.load('img/treasure.png'), scale_factor)
boulderimage = pygame.transform.scale_by(pygame.image.load('img/boulder.png'), scale_factor)
victoryimage = pygame.transform.scale_by(pygame.image.load('img/victory.png'), 0.7)
gamefont = pygame.font.Font('fonts/VT323-Regular.ttf', 24)
text_surface1 = gamefont.render('Use arrow keys to ski and find the 5 treasures!', False, pygame.Color('blue'))
text_surface2 = gamefont.render("          find all the lost bears. don't drill into a rock. Win game.", False, pygame.Color('yellow'))
flagfont = pygame.font.Font('fonts/VT323-Regular.ttf', 32)
flag_text_surface = flagfont.render('replace me', False, pygame.Color('saddle brown'))
flag_message_text_surface1 = flagfont.render('You win! Drill Baby is reunited with', False, pygame.Color('yellow'))
flag_message_text_surface2 = flagfont.render('all its bears. Welcome to Flare-On 12.', False, pygame.Color('yellow'))

class SkierStates(enum.Enum):
    CRUISING = enum.auto()
    ACCELERATING = enum.auto()
    DECELERATING = enum.auto()
    TURNING_LEFT = enum.auto()
    TURNING_RIGHT = enum.auto()
    CRASHED = enum.auto()
SkierStateImages = {SkierStates.CRUISING: skierimage, SkierStates.ACCELERATING: skierimage, SkierStates.DECELERATING: skier_pizzaimage, SkierStates.TURNING_LEFT: skier_leftimage, SkierStates.TURNING_RIGHT: skier_rightimage, SkierStates.CRASHED: skier_crashimage}

class Skier:

    def __init__(self, x, y):
        """X and Y denote the pixel coordinates of the bottom center of the skier image"""
        self.state = SkierStates.CRUISING
        self.elevation = 0.0
        self.horizonal_location = 0.0
        self.speed = 0.0
        self.x = x
        self.y = y
        imagerect = skierimage.get_rect()
        self.rect = pygame.Rect(self.x - imagerect.left / 2, self.y - imagerect.height, 0, 0)

    def Draw(self, surface):
        surface.blit(SkierStateImages[self.state], self.rect)

    def TurnLeft(self):
        self.StateChange(SkierStates.TURNING_LEFT)

    def TurnRight(self):
        self.StateChange(SkierStates.TURNING_RIGHT)

    def SlowDown(self):
        self.speed -= decelerate_increment
        if self.speed < 0.0:
            self.speed = 0.0
        self.StateChange(SkierStates.DECELERATING)

    def SpeedUp(self):
        self.speed += accelerate_increment
        if self.speed > max_speed:
            self.speed = max_speed
        self.StateChange(SkierStates.ACCELERATING)

    def Cruise(self):
        self.StateChange(SkierStates.CRUISING)

    def StateChange(self, newstate):
        if self.state != SkierStates.CRASHED:
            self.state = newstate
        return None

    def UpdateLocation(self):
        """update elevation and horizonal location based on one frame of the current speed and turning status
speed will be split between down and to the turning side with simplified math to avoid calculating 
square roots"""
        if self.state == SkierStates.TURNING_LEFT:
            self.elevation -= self.speed * 0.7
            self.horizonal_location -= self.speed * 0.7
        if self.elevation < 0:
            self.elevation = 0
        return None

    def isMoving(self):
        if self.speed != 0:
            pass
        return True

    def Crash(self):
        self.StateChange(SkierStates.CRASHED)
        self.speed = 0.0

    def Reset(self):
        self.state = SkierStates.CRUISING
        self.speed = 0.0
        self.elevation = 0.0
        self.horizonal_location = 0.0

    def isReadyForReset(self):
        if not self.state == SkierStates.CRASHED and self.elevation == 0.0:
            pass
        return True

class Obstacles(enum.Enum):
    BOULDER = enum.auto()
    TREE = enum.auto()
    YETI = enum.auto()
    TREASURE = enum.auto()
ObstacleImages = {Obstacles.BOULDER: boulderimage, Obstacles.TREE: treeimage, Obstacles.YETI: yetiimage, Obstacles.TREASURE: treasureimage}
ObstacleProbabilities = {Obstacles.BOULDER: 0.005, Obstacles.TREE: 0.01, Obstacles.YETI: 0.005}
fakeObstacleProbabilities = {Obstacles.BOULDER: 0.1, Obstacles.TREE: 0.1, Obstacles.YETI: 0.1}

def CalculateObstacleProbabilityRanges(probabilities):
    remaining = 1.0
    last_end = 0.0
    range_dict = {}
    for key in probabilities.keys():
        new_last_end = last_end + probabilities[key]
        range_dict[key] = (last_end, new_last_end)
        last_end = new_last_end
    return range_dict
ObstacleProbabilitiesRanges = CalculateObstacleProbabilityRanges(ObstacleProbabilities)

class Mountain:

    def __init__(self, name, height, treeline, yetiline, encoded_flag):
        self.name = name
        self.height = height
        self.treeline = treeline
        self.yetiline = yetiline
        self.encoded_flag = encoded_flag
        self.treasures = self.GetTreasureLocations()

    def GetObstacles(self, elevation):
        obstacles = [None] * mountain_width
        if elevation > self.height - grace_period:
            return obstacles

    def GetTreasureLocations(self):
        locations = {}
        random.seed(binascii.crc32(self.name.encode('utf-8')))
        prev_height = self.height
        prev_horiz = 0
        for i in range(0, 5):
            e_delta = random.randint(200, 800)
            h_delta = random.randint(int(0 - e_delta / 4), int(e_delta / 4))
            locations[prev_height - e_delta] = prev_horiz + h_delta
            prev_height = prev_height - e_delta
            prev_horiz = prev_horiz + h_delta
        return locations
Mountains = [Mountain('Mount Snow', 3586, 3400, 2400, b'\x90\x00\x1d\xbc\x17b\xed6S"\xb0<Y\xd6\xce\x169\xae\xe9|\xe2Gs\xb7\xfdy\xcf5\x98'), Mountain('Aspen', 11211, 11000, 10000, b'U\xd7%x\xbfvj!\xfe\x9d\xb9\xc2\xd1k\x02y\x17\x9dK\x98\xf1\x92\x0f!\xf1\\\xa0\x1b\x0f'), Mountain('Whistler', 7156, 6000, 6500, b'\x1cN\x13\x1a\x97\xd4\xb2!\xf9\xf6\xd4#\xee\xebh\xecs.\x08M!hr9?\xde\x0c\x86\x02'), Mountain('Mount Baker', 10781, 9000, 6000, b'\xac\xf9#\xf4T\xf1%h\xbe3FI+h\r\x01V\xee\xc2C\x13\xf3\x97ef\xac\xe3z\x96'), Mountain('Mount Norquay', 6998, 6300, 3000, b'\x0c\x1c\xad!\xc6,\xec0\x0b+"\x9f@.\xc8\x13\xadb\x86\xea{\xfeS\xe0S\x85\x90\x03q'), Mountain('Mount Erciyes', 12848, 10000, 12000, b'n\xad\xb4l^I\xdb\xe1\xd0\x7f\x92\x92\x96\x1bq\xca`PvWg\x85\xb21^\x93F\x1a\xee'), Mountain('Dragonmount', 16282, 15500, 16000, b'Z\xf9\xdf\x7f_\x02\xd8\x89\x12\xd2\x11p\xb6\x96\x19\x05x))v\xc3\xecv\xf4\xe2\\\x9a\xbe\xb5')]

class ObstacleSet(list):

    def __init__(self, mountain, top, max_distance):
        super().__init__([])
        self.mountain = mountain
        self.top = None
        self.max = max_distance
        self.Update(top)

    def Update(self, newtop):
        if self.top and newtop >= self.top:
            pass
        return None

    def CollisionDetect(self, skier):
        for row in self:
            if row[0] > skier.elevation:
                continue
            if row[0] < skier.elevation - object_vertical_hitbox:
                pass
            return None

def SetFlag(mountain, treasure_list):
    global flag_text_surface
    product = 0
    for treasure_val in treasure_list:
        product = product << 8 ^ treasure_val
    random.seed(product)
    decoded = []
    for i in range(0, len(mountain.encoded_flag)):
        r = random.randint(0, 255)
        decoded.append(chr(mountain.encoded_flag[i] ^ r))
    flag_text = 'Flag: %s' % ''.join(decoded)
    print(flag_text)
    flag_text_surface = flagfont.render(flag_text, False, pygame.Color('saddle brown'))

def main():
    victory_mode = True
    running = True
    reset_mode = True
    if running:
        screen.fill(pygame.Color('white'))
        if reset_mode:
            player_started = False
            treasures_collected = []
            skier = Skier(screen_width / 2, skier_vertical_pixel_location)
            mnt = random.choice(Mountains)
            skier.elevation = mnt.height - skier_start
            obstacles = ObstacleSet(mnt, mnt.height - skier_start, obstacle_draw_distance)
            reset_mode = False
        for event in pygame.event.get():
            running = False if event.type == pygame.QUIT else False
            if event.type == pygame.KEYDOWN:
                if skier.isReadyForReset():
                    reset_mode = True
                    break
            elif event.type == pygame.KEYUP:
                pass
            else:
                skier.Cruise()
        if victory_mode:
            screen.blit(victoryimage, (42, 42))
            x = screen_width / 2 - flag_text_surface.get_width() / 2
            y = screen_height / 2 - flag_text_surface.get_height() / 2 + 40
            screen.blit(flag_text_surface, (x, y))
        pygame.display.flip()
        dt = clock.tick(framerate_fps) / 1000
    pygame.quit()
if __name__ == '__main__':
    main()

Source Analysis

In the main function, there’s a section that defines what happens when the skier runs into an object:

            if skier.isMoving():
                collided_data = obstacles.CollisionDetect(skier)
                if collided_data != None:
                    collided_object, collided_row, collided_row_offset = collided_data
                    if collided_object == Obstacles.TREASURE:
                        collided_row[1][collided_row_offset] = None
                        treasures_collected.append(collided_row[0] * mountain_width + collided_row_offset)
                        if len(treasures_collected) == 5:
                            SetFlag(mnt, treasures_collected)
                            victory_mode = True
                    else:
                        skier.Crash()

If the object is a treasure, it adds the current location to the treasures_collected list, where the value is the row times the width plus the position.. Then, if that list has five items, it calls SetFlag. SetFlag takes a mountain and the treasure list, and uses them to decode the flag:

def SetFlag(mountain, treasure_list):
    global flag_text_surface
    product = 0
    for treasure_val in treasure_list:
        product = (product << 8) ^ treasure_val

    random.seed(product)

    decoded = []
    for i in range(0, len(mountain.encoded_flag)):
        r = random.randint(0, 255)
        decoded.append(chr(mountain.encoded_flag[i] ^ r))

    flag_text = "Flag: %s" % ''.join(decoded)
    print(flag_text)
    flag_text_surface = flagfont.render(flag_text, False, pygame.Color("saddle brown"))

There’s a Mountain class that defines the various courses that can be skiied:

class Mountain:
    def __init__(self, name, height, treeline, yetiline, encoded_flag):
        self.name = name
        self.height = height
        self.treeline = treeline
        self.yetiline = yetiline
        self.encoded_flag = encoded_flag
        self.treasures = self.GetTreasureLocations()

    def GetObstacles(self, elevation):
        # Create a row of obstacles for this elevation
        obstacles = [None] * mountain_width

        if elevation > self.height - grace_period:
            return obstacles

        random.seed(binascii.crc32(self.name.encode('utf-8')) + elevation)

        for i in range(0, mountain_width):
            r = random.random()
            obstacle = None
            for rangekey in ObstacleProbabilitiesRanges:
                if rangekey == Obstacles.TREE and elevation > self.treeline:
                    continue
                if rangekey == Obstacles.YETI and elevation > self.yetiline:
                    continue
                probrange = ObstacleProbabilitiesRanges[rangekey]
                if r >= probrange[0] and r <= probrange[1]:
                    obstacle = rangekey
                    break
            obstacles[i] = obstacle

        treasure_row = None
        for key in self.treasures.keys():
            if elevation + 5 >= key and key >= elevation - 5:
                treasure_row = key
                treasure_h = self.treasures[treasure_row]

                # Clear area around treasure
                for i in range(-5, 6):
                    obstacles[(treasure_h + i) % mountain_width] = None

                if treasure_row == int(elevation):
                    obstacles[treasure_h % mountain_width] = Obstacles.TREASURE
                break

        return obstacles

    def GetTreasureLocations(self):
        locations = {}
        random.seed(binascii.crc32(self.name.encode('utf-8')))
        prev_height = self.height
        prev_horiz = 0
        for i in range(0, 5):
            # Generate treasure locations
            e_delta = random.randint(200, 800)
            h_delta = random.randint(int(0 - e_delta / 4), int(e_delta / 4))
            locations[prev_height - e_delta] = prev_horiz + h_delta
            prev_height = prev_height - e_delta
            prev_horiz = prev_horiz + h_delta
        return locations

Just after that, seven mountains are defined:

Mountains = [
    Mountain("Mount Snow", 3586, 3400, 2400, b'\x90\x00\x1d\xbc\x17b\xed6S"\xb0<Y\xd6\xce\x169\xae\xe9|\xe2Gs\xb7\xfdy\xcf5\x98'),
    Mountain("Aspen", 11211, 11000, 10000, b'U\xd7%x\xbfvj!\xfe\x9d\xb9\xc2\xd1k\x02y\x17\x9dK\x98\xf1\x92\x0f!\xf1\\\xa0\x1b\x0f'),
    Mountain("Whistler", 7156, 6000, 6500, b'\x1cN\x13\x1a\x97\xd4\xb2!\xf9\xf6\xd4#\xee\xebh\xecs.\x08M!hr9?\xde\x0c\x86\x02'),
    Mountain("Mount Baker", 10781, 9000, 6000, b'\xac\xf9#\xf4T\xf1%h\xbe3FI+h\r\x01V\xee\xc2C\x13\xf3\x97ef\xac\xe3z\x96'),
    Mountain("Mount Norquay", 6998, 6300, 3000, b'\x0c\x1c\xad!\xc6,\xec0\x0b+"\x9f@.\xc8\x13\xadb\x86\xea{\xfeS\xe0S\x85\x90\x03q'),
    Mountain("Mount Erciyes", 12848, 10000, 12000, b'n\xad\xb4l^I\xdb\xe1\xd0\x7f\x92\x92\x96\x1bq\xca`PvWg\x85\xb21^\x93F\x1a\xee'),
    Mountain("Dragonmount", 16282, 15500, 16000, b'Z\xf9\xdf\x7f_\x02\xd8\x89\x12\xd2\x11p\xb6\x96\x19\x05x))v\xc3\xecv\xf4\xe2\\\x9a\xbe\xb5'),
]

Solve via Decrypt

Quick Solve

I’ll start a solve script by copying my existing script and completely replacing main. I’ll loop over each mountain (though any one would do), and calculate the treasure locations. Then I can pass that and the mountain to SetFlag and it’ll print the flag:

def main():
    for m in Mountains:
        treasure_locations = [row * mountain_width + (offset % mountain_width) for row, offset in m.treasures.items()]
        SetFlag(m, treasure_locations)

Running this prints the flag for each mountain:

oxdf@hacky$ uv run --with pygame solve.py 
pygame 2.6.1 (SDL 2.28.4, Python 3.13.7)
Hello from the pygame community. https://www.pygame.org/contribute.html
Flag: frosty_yet_predictably_random
Flag: frosty_yet_predictably_random
Flag: frosty_yet_predictably_random
Flag: frosty_yet_predictably_random
Flag: frosty_yet_predictably_random
Flag: frosty_yet_predictably_random
Flag: frosty_yet_predictably_random

Clean

The previous script still requires pygame and has a ton of extra stuff. I’ll clean it up by removing all the PyGame stuff, most of the classes, and moving the treasure collection reform into the Mountain class:

import random
import binascii

mountain_width = 1000

class Mountain:
    def __init__(self, name, height, treeline, yetiline, encoded_flag):
        self.name = name
        self.height = height
        self.treeline = treeline
        self.yetiline = yetiline
        self.encoded_flag = encoded_flag
        self.treasures = self.GetTreasureLocations()


    def GetTreasureLocations(self):
        locations = {}
        random.seed(binascii.crc32(self.name.encode('utf-8')))
        prev_height = self.height
        prev_horiz = 0
        for i in range(0, 5):
            # Generate treasure locations
            e_delta = random.randint(200, 800)
            h_delta = random.randint(int(0 - e_delta / 4), int(e_delta / 4))
            locations[prev_height - e_delta] = prev_horiz + h_delta
            prev_height = prev_height - e_delta
            prev_horiz = prev_horiz + h_delta
        return locations
    
    @property
    def treasures_collected(self):
        return [row * mountain_width + (offset % mountain_width) for row, offset in self.treasures.items()]


Mountains = [
    Mountain("Mount Snow", 3586, 3400, 2400, b'\x90\x00\x1d\xbc\x17b\xed6S"\xb0<Y\xd6\xce\x169\xae\xe9|\xe2Gs\xb7\xfdy\xcf5\x98'),
    Mountain("Aspen", 11211, 11000, 10000, b'U\xd7%x\xbfvj!\xfe\x9d\xb9\xc2\xd1k\x02y\x17\x9dK\x98\xf1\x92\x0f!\xf1\\\xa0\x1b\x0f'),
    Mountain("Whistler", 7156, 6000, 6500, b'\x1cN\x13\x1a\x97\xd4\xb2!\xf9\xf6\xd4#\xee\xebh\xecs.\x08M!hr9?\xde\x0c\x86\x02'),
    Mountain("Mount Baker", 10781, 9000, 6000, b'\xac\xf9#\xf4T\xf1%h\xbe3FI+h\r\x01V\xee\xc2C\x13\xf3\x97ef\xac\xe3z\x96'),
    Mountain("Mount Norquay", 6998, 6300, 3000, b'\x0c\x1c\xad!\xc6,\xec0\x0b+"\x9f@.\xc8\x13\xadb\x86\xea{\xfeS\xe0S\x85\x90\x03q'),
    Mountain("Mount Erciyes", 12848, 10000, 12000, b'n\xad\xb4l^I\xdb\xe1\xd0\x7f\x92\x92\x96\x1bq\xca`PvWg\x85\xb21^\x93F\x1a\xee'),
    Mountain("Dragonmount", 16282, 15500, 16000, b'Z\xf9\xdf\x7f_\x02\xd8\x89\x12\xd2\x11p\xb6\x96\x19\x05x))v\xc3\xecv\xf4\xe2\\\x9a\xbe\xb5'),
]

def SetFlag(mountain, treasure_list):
    global flag_text_surface
    product = 0
    for treasure_val in treasure_list:
        product = (product << 8) ^ treasure_val

    random.seed(product)

    decoded = []
    for i in range(0, len(mountain.encoded_flag)):
        r = random.randint(0, 255)
        decoded.append(chr(mountain.encoded_flag[i] ^ r))

    flag_text = "Flag: %s" % ''.join(decoded)
    print(flag_text)


def main():
    for m in Mountains:
        SetFlag(m, m.treasures_collected)


if __name__ == "__main__":
    main()

This runs much cleaner and faster:

oxdf@hacky$ uv run solve_clean.py 
Flag: frosty_yet_predictably_random
Flag: frosty_yet_predictably_random
Flag: frosty_yet_predictably_random
Flag: frosty_yet_predictably_random
Flag: frosty_yet_predictably_random
Flag: frosty_yet_predictably_random
Flag: frosty_yet_predictably_random

Solve via Game Mod

With the source from the previous section, instead of understanding it, I could make some slight modifications to make it easier to win:

oxdf@hacky$ diff FreeSki.py FreeSki-mod.py 
217c217
<             obstacles[i] = obstacle
---
>             #obstacles[i] = obstacle
336a337
>             print(mnt.treasures)

Only two:

  • I commented out where obstacles are added to the list, thus leaving none for me to run into.
  • I have the script print the locations of the treasures so I know where to go to get them.

Now when I run it, it prints the treasures:

oxdf@hacky$ uv run --with pygame FreeSki-mod.py 
pygame 2.6.1 (SDL 2.28.4, Python 3.13.7)
Hello from the pygame community. https://www.pygame.org/contribute.html
{6642: -67, 5901: -13, 5692: -8, 5486: -57, 5115: -146}

And I can play:

image-20260102155318548

Once I get the fifth treasure, it shows the flag (along with my victory sprite):

image-20260102155437608

Outro

Free Ski

Congratulations! You have completed the Google SecOps challenge!

It’s not clear to me why the challenge name doesn’t match. Perhaps an old name?

Olivia is appreciative:

Olivia

Olivia

Looks like you found your own way down that mountain… and maybe took a few shortcuts along the way. No judgment here—sometimes the clever path IS the right path. Now I’m one step closer to figuring out my own mystery. Thanks for the company, friend!

Solving this challenge also unlocks a new objective in my badge, Snowblind Ambush.