Holiday Hack 2025: Free Ski
Introduction
Free Ski
Difficulty:❅❅❅❅❅Olivia (aka the Goose of Space Island) is up on the table in the Retro Emporium:
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
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:
If I crash, it shows the crash icon:
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_compileorcompileallmodules)
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
productto 0 -
305-306 makes a loop to build the key. It’s looping over the
treasure_listvariable and using bit shifting operations and xor to combine it intoproduct. It is effectively:for treasure_val in treasure_list: product = (product << 8) ^ treasure_valThis 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 todecoded. -
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_objectis aTREASURE, 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). Herecollided_row[0]is the elevation (row number) andcollided_row_offsetis the horizontal position. Withmountain_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)andvictory_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
locationsas 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_horizto 0. - 241 sets up a loop for five iterations.
- 243 sets
e_deltato a random int between 200 and 800. - 244 sets
h_deltato a random int between-e_delta/4ande_delta/4. - 245 sets
locations[prev_height - e_delta] = prev_horiz + h_delta - 246 and 247 update
prev_heightandprev_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:
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:
Once I get the fifth treasure, it shows the flag (along with my victory sprite):
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
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.