launch-calcium commited on
Commit
0dd525f
·
verified ·
1 Parent(s): 0403886

Upload 3 files

Browse files
Files changed (3) hide show
  1. GEOMETRY_DASH_MIDI_PLAN.md +96 -0
  2. midi_to_gd.py +371 -0
  3. test_midi_to_gd.py +85 -0
GEOMETRY_DASH_MIDI_PLAN.md ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Geometry Dash Level Creation from MIDI Files - Design and Implementation Plan
2
+
3
+ This document outlines the detailed plan, architecture, and mathematical mappings required to convert MIDI musical data into fully editable, playable Geometry Dash 2.11 custom levels.
4
+
5
+ ---
6
+
7
+ ## 1. Overview and Core Concept
8
+
9
+ The goal of this system is to act as a **virtual piano roll** in Geometry Dash.
10
+ - **Horizontal Axis (X-axis) represents Time**: The horizontal coordinate translates to the musical beats/tempo of the song. Longer music notes result in longer horizontal lines of blocks.
11
+ - **Vertical Axis (Y-axis) represents Pitch**: Instead of spreading pitches sparsely across all 128 standard MIDI keys, the system extracts the **active pitches** from the MIDI, sorts them, and assigns them sequential Y-levels. Each consecutive unique pitch is separated by exactly **1 grid block (30 Y-units)**.
12
+ - **Visual Feedback via 8-Color Hue Channels**: Blocks are mapped to Geometry Dash's native Color Channels `1` through `8` based on their pitch class (e.g., `pitch % 8` or `pitch_index % 8`), creating a beautiful, rainbow-colored piano roll.
13
+
14
+ ---
15
+
16
+ ## 2. Cryptography and Level Serialization
17
+
18
+ ### 2.1 Decrypting & Encrypting `CCLocalLevels.dat`
19
+ In Windows, the Geometry Dash save file is located at:
20
+ `%localappdata%\GeometryDash\CCLocalLevels.dat`
21
+
22
+ The save file is encrypted with a custom algorithm. The decryption process in Python:
23
+ 1. **XOR Decryption**: Read file as bytes, and XOR each byte with `0x0B` (`11`).
24
+ 2. **Base64 Decode**: Convert URL-safe base64 characters (`-` and `_` to `+` and `/`), and pad the string with `=` so its length is a multiple of 4. Decode base64 bytes.
25
+ 3. **Gzip Decompression**: Decompress using standard Gzip/Zlib library. This yields a clean XML string (`<plist>...`).
26
+
27
+ Encryption is the exact reverse process:
28
+ 1. **Gzip Compression**: Compress XML string with Gzip.
29
+ 2. **Base64 Encode**: Encode bytes to Base64 and replace standard base64 characters (`+` and `/` with `-` and `_`), stripping any trailing padding `=`.
30
+ 3. **XOR Encryption**: XOR each byte with `0x0B` (`11`) and write to disk.
31
+
32
+ ### 2.2 Level String Compression (`k4` Key)
33
+ Within the `CCLocalLevels.dat` XML structure, a level is represented as a dictionary under key `<k>k_0</k>` (or other indices). Inside this level dictionary:
34
+ - `<k>k2</k><s>[Level Name]</s>` represents the Level Name.
35
+ - `<k>k4</k><s>[Level String]</s>` represents the actual object/setting data.
36
+ - The `k4` value itself is a compressed level string. It is encrypted/compressed similarly to the save file (Base64 + Gzip) but **without** the initial XOR 11 key.
37
+ - Once decompressed, the level string looks like:
38
+ `kS1,30,kS2,35,...;[Object 1];[Object 2];[Object 3];...`
39
+ - The first segment (before the first `;`) defines the level settings/header. Semicolons `;` separate individual objects.
40
+
41
+ ---
42
+
43
+ ## 3. Level Coordinate & Object Mappings
44
+
45
+ ### 3.1 Object Anatomy
46
+ Each object is a comma-separated key-value list. For our generated virtual piano roll, we will use the basic solid cube block:
47
+ - **Object ID (`1`)**: Basic solid block = `1`.
48
+ - **Keys**:
49
+ - `1`: Object ID (`1` for solid block)
50
+ - `2`: X Position (Units, where 1 grid square = 30 units)
51
+ - `3`: Y Position (Units, where 1 grid square = 30 units)
52
+ - `21`: Color Channel ID (`1` through `8`)
53
+ - `32`: Scale (`1.0`)
54
+
55
+ Example Block: `1,1,2,300,3,450,21,3` (Solid block at X=300, Y=450 on Color Channel 3).
56
+
57
+ ### 3.2 Pitch to Y-Axis Mapping
58
+ To make the level clean and playable:
59
+ 1. We parse the MIDI file and find all unique pitches used across all tracks.
60
+ 2. Let $P = [p_0, p_1, \dots, p_{n-1}]$ be the sorted list of these active MIDI pitches (where $p_0 < p_1 < \dots < p_{n-1}$).
61
+ 3. We set a baseline Y position (e.g., $Y_{\text{base}} = 15 \times 30 = 450$ units, representing 15 blocks high).
62
+ 4. Each pitch $p_i$ is mapped to:
63
+ $$Y(p_i) = Y_{\text{base}} + i \times 30 \text{ units}$$
64
+ 5. This guarantees that notes are stacked directly on top of each other with zero vertical gaps.
65
+
66
+ ### 3.3 Time/Beat to X-Axis Mapping
67
+ 1. A standard beat grid is defined. Let $B$ be the number of GD grid blocks per beat (quarter note). By default, we use $B = 2$ blocks per beat.
68
+ 2. In GD, 1 block = 30 units. Therefore, 1 beat = $30 \times B$ horizontal units (e.g., 60 units).
69
+ 3. Given a MIDI note starting at beat $t_{\text{start}}$ (in quarter notes) with a duration of $d$ beats:
70
+ - **X Start position**: $X_{\text{start}} = t_{\text{start}} \times B \times 30$ units.
71
+ - **Length in blocks**: $L = d \times B$.
72
+ - We place $\lceil L \rceil$ consecutive blocks side-by-side to construct a solid line representing the duration of the musical note.
73
+ - For example, if a note starts at beat 4 and lasts 3 beats, with $B=2$, it starts at $X = 4 \times 2 \times 30 = 240$ and spans $3 \times 2 = 6$ blocks (placing blocks at X=240, 270, 300, 330, 360, 390).
74
+
75
+ ### 3.4 Color Assignment
76
+ Each block's color channel (from 1 to 8) is determined by its active pitch index:
77
+ $$\text{Color Channel} = 1 + (i \pmod 8)$$
78
+ This gives an elegant, repeating rainbow gradient corresponding to rising pitch heights.
79
+
80
+ ---
81
+
82
+ ## 4. Implementation Details (`midi_to_gd.py`)
83
+
84
+ A fully-featured Python tool will be written. It will support:
85
+ 1. **MIDI Parsing**: Utilizing `mido` (and falling back to a custom lightweight parser or installing `mido` via pip in the user's environment).
86
+ 2. **Direct Save Insertion**: Decrypts the user's local `CCLocalLevels.dat`, inserts the newly generated level string, and re-encrypts the save file.
87
+ 3. **Alternative Exports**: Supports exporting a standalone decrypted XML level file or a base64 level string for manual importing.
88
+
89
+ ---
90
+
91
+ ## 5. Implementation Roadmap
92
+ 1. Write this design document (`GEOMETRY_DASH_MIDI_PLAN.md`).
93
+ 2. Implement `midi_to_gd.py` CLI script.
94
+ 3. Implement `test_midi_to_gd.py` test suite.
95
+ 4. Execute test suites and verify all cryptographical and mapping functionality works correctly.
96
+ 5. Deliver and document usage instructions.
midi_to_gd.py ADDED
@@ -0,0 +1,371 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ MIDI to Geometry Dash Level Creator (GD 2.11)
4
+ Parses a MIDI file and transforms music notes into horizontal lines of solid cube blocks
5
+ aligned to beats and structured like a beautiful vertical virtual piano roll.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ import base64
11
+ import gzip
12
+ import zlib
13
+ import re
14
+ import argparse
15
+ import xml.etree.ElementTree as ET
16
+ from typing import List, Dict, Tuple, Set, Optional
17
+
18
+ # Attempt to import mido
19
+ try:
20
+ import mido
21
+ except ImportError:
22
+ print("Error: 'mido' library is required to parse MIDI files.")
23
+ print("Please install it using: pip install mido")
24
+ sys.exit(1)
25
+
26
+
27
+ def xor_data(data: bytes, key: int = 11) -> bytes:
28
+ """XORs bytes with a given key (default: 11 / 0x0B)."""
29
+ return bytes([b ^ key for b in data])
30
+
31
+
32
+ def decrypt_save_file(filepath: str) -> str:
33
+ """
34
+ Decrypts a standard Windows Geometry Dash CCLocalLevels.dat or CCGameManager.dat.
35
+ Algorithm: XOR 11 -> base64 decode (with url-safe correction) -> gzip decompress
36
+ """
37
+ with open(filepath, "rb") as f:
38
+ encrypted_data = f.read()
39
+
40
+ # Step 1: XOR with 11
41
+ xor_decrypted = xor_data(encrypted_data, 11)
42
+
43
+ # Step 2: Base64 decode with URL-safe replacement
44
+ # standard base64 replacements for URL safe decoding
45
+ b64_str = xor_decrypted.decode("utf-8", errors="ignore")
46
+ normalized_b64 = b64_str.replace("-", "+").replace("_", "/")
47
+
48
+ # Add base64 padding if needed
49
+ padding_needed = len(normalized_b64) % 4
50
+ if padding_needed:
51
+ normalized_b64 += "=" * (4 - padding_needed)
52
+
53
+ compressed_bytes = base64.b64decode(normalized_b64.encode("utf-8"))
54
+
55
+ # Step 3: Gzip decompress
56
+ # Gzip uses header bytes, standard gzip decompression or zlib decompress (with wbits magic)
57
+ try:
58
+ decompressed_data = gzip.decompress(compressed_bytes)
59
+ except Exception:
60
+ # Sometimes zlib decompression with no header is needed if gzip.decompress fails
61
+ decompressed_data = zlib.decompress(compressed_bytes, -zlib.MAX_WBITS)
62
+
63
+ return decompressed_data.decode("utf-8", errors="ignore")
64
+
65
+
66
+ def encrypt_save_file(xml_data: str) -> bytes:
67
+ """
68
+ Encrypts a clean XML plist into a CCLocalLevels.dat compatible format.
69
+ Algorithm: gzip compress -> base64 encode -> URL-safe replace -> XOR 11
70
+ """
71
+ # Step 1: Gzip compress
72
+ compressed = gzip.compress(xml_data.encode("utf-8"))
73
+
74
+ # Step 2: Base64 encode
75
+ b64_encoded = base64.b64encode(compressed).decode("utf-8")
76
+
77
+ # Step 3: URL safe replace
78
+ url_safe_b64 = b64_encoded.replace("+", "-").replace("/", "_")
79
+
80
+ # Strip padding
81
+ url_safe_b64 = url_safe_b64.rstrip("=")
82
+
83
+ # Step 4: XOR with 11
84
+ encrypted_bytes = xor_data(url_safe_b64.encode("utf-8"), 11)
85
+ return encrypted_bytes
86
+
87
+
88
+ def decrypt_level_string(level_string: str) -> str:
89
+ """
90
+ Decrypts the actual object string inside the level string (<k>k4</k>).
91
+ This string is NOT XORed, only Base64 decoded -> gzip decompressed.
92
+ """
93
+ normalized = level_string.replace("-", "+").replace("_", "/")
94
+ padding_needed = len(normalized) % 4
95
+ if padding_needed:
96
+ normalized += "=" * (4 - padding_needed)
97
+
98
+ compressed_bytes = base64.b64decode(normalized.encode("utf-8"))
99
+ try:
100
+ decompressed = gzip.decompress(compressed_bytes)
101
+ except Exception:
102
+ decompressed = zlib.decompress(compressed_bytes, -zlib.MAX_WBITS)
103
+ return decompressed.decode("utf-8", errors="ignore")
104
+
105
+
106
+ def encrypt_level_string(decrypted_level: str) -> str:
107
+ """
108
+ Encrypts the decrypted object string back into the standard compressed k4 level string format.
109
+ """
110
+ compressed = gzip.compress(decrypted_level.encode("utf-8"))
111
+ b64_encoded = base64.b64encode(compressed).decode("utf-8")
112
+ return b64_encoded.replace("+", "-").replace("/", "_").rstrip("=")
113
+
114
+
115
+ class MIDINote:
116
+ def __init__(self, pitch: int, start_time: float, end_time: float, velocity: int):
117
+ self.pitch = pitch
118
+ self.start_time = start_time # in seconds or beats
119
+ self.end_time = end_time # in seconds or beats
120
+ self.velocity = velocity
121
+
122
+ @property
123
+ def duration(self) -> float:
124
+ return self.end_time - self.start_time
125
+
126
+
127
+ def parse_midi_notes(midi_path: str) -> List[MIDINote]:
128
+ """
129
+ Parses a MIDI file and extracts all note-on/note-off events,
130
+ converting times to beats (quarter notes).
131
+ """
132
+ mid = mido.MidiFile(midi_path)
133
+ ticks_per_beat = mid.ticks_per_beat
134
+ notes: List[MIDINote] = []
135
+
136
+ # Map tracking currently active note starts per channel and pitch
137
+ # key: (channel, pitch), value: (start_beat, velocity)
138
+ active_notes: Dict[Tuple[int, int], Tuple[float, int]] = {}
139
+
140
+ for track in mid.tracks:
141
+ current_ticks = 0
142
+ for msg in track:
143
+ current_ticks += msg.time
144
+ # Convert ticks to beats (quarter notes)
145
+ current_beat = current_ticks / ticks_per_beat
146
+
147
+ if msg.type == 'note_on' and msg.velocity > 0:
148
+ key = (msg.channel, msg.note)
149
+ # If note was already active (rare without note_off), close it first
150
+ if key in active_notes:
151
+ start_beat, vel = active_notes.pop(key)
152
+ notes.append(MIDINote(msg.note, start_beat, current_beat, vel))
153
+ active_notes[key] = (current_beat, msg.velocity)
154
+
155
+ elif msg.type == 'note_off' or (msg.type == 'note_on' and msg.velocity == 0):
156
+ key = (msg.channel, msg.note)
157
+ if key in active_notes:
158
+ start_beat, vel = active_notes.pop(key)
159
+ # Add note if duration is greater than 0
160
+ if current_beat > start_beat:
161
+ notes.append(MIDINote(msg.note, start_beat, current_beat, vel))
162
+
163
+ # Close any notes that remained active until the end of the track
164
+ # Use max length as end point
165
+ max_beat = max([n.end_time for n in notes]) if notes else 100.0
166
+ for (channel, pitch), (start_beat, vel) in active_notes.items():
167
+ if max_beat > start_beat:
168
+ notes.append(MIDINote(pitch, start_beat, max_beat, vel))
169
+
170
+ return notes
171
+
172
+
173
+ def generate_gd_level_objects(notes: List[MIDINote], blocks_per_beat: float = 2.0) -> str:
174
+ """
175
+ Translates a list of MIDI notes into a string of Geometry Dash level objects.
176
+ Each unique pitch is mapped to a unique vertical lane.
177
+ Returns: The raw, decompressed object string.
178
+ """
179
+ if not notes:
180
+ return ""
181
+
182
+ # 1. Identify all unique pitches and sort them to assign Y coordinates
183
+ unique_pitches = sorted(list(set(note.pitch for note in notes)))
184
+ pitch_to_y_index = {pitch: i for i, pitch in enumerate(unique_pitches)}
185
+
186
+ # Base coordinates:
187
+ # 1 grid unit in GD = 30 units
188
+ # Start baseline height at Y = 15 blocks (450 units)
189
+ y_base = 15 * 30
190
+ x_offset = 10 * 30 # Start building 10 blocks into the level to give the player a safe start
191
+
192
+ objects: List[str] = []
193
+
194
+ # Format of an object: "key,value,key,value..."
195
+ # 1: ObjID (1 = Solid Cube Block)
196
+ # 2: X position
197
+ # 3: Y position
198
+ # 21: Color Channel
199
+ for note in notes:
200
+ y_index = pitch_to_y_index[note.pitch]
201
+ y_pos = y_base + y_index * 30
202
+
203
+ # Calculate time-to-X coordinates
204
+ start_x = x_offset + (note.start_time * blocks_per_beat) * 30
205
+ duration_blocks = note.duration * blocks_per_beat
206
+
207
+ # Determine number of blocks to place to span the duration of the note
208
+ # Always place at least 1 block
209
+ num_blocks = max(1, int(round(duration_blocks)))
210
+
211
+ # Assign color channel (1 through 8) based on pitch index
212
+ color_channel = 1 + (y_index % 8)
213
+
214
+ # Place the blocks horizontally side-by-side
215
+ for b in range(num_blocks):
216
+ block_x = start_x + b * 30
217
+ # Basic Solid Cube Block format: obj_id,1, x,block_x, y,y_pos, color_channel,color_channel
218
+ # Form: 1,1,2,block_x,3,y_pos,21,color_channel
219
+ obj_str = f"1,1,2,{block_x:.1f},3,{y_pos:.1f},21,{color_channel}"
220
+ objects.append(obj_str)
221
+
222
+ # Combine all objects separated by semicolons
223
+ return ";".join(objects) + ";"
224
+
225
+
226
+ def build_full_level_string(objects_string: str) -> str:
227
+ """
228
+ Prepends standard level header/settings to the objects string.
229
+ """
230
+ # Standard GD 2.11 empty level header
231
+ header = "kS1,30,kS2,35,kS3,41,kS4,115,kS5,115,kS6,115,kS7,0,kS8,0,kS9,0,kS10,0,kS11,0,kS12,0,kS13,0,kS14,0,kS15,0,kS16,0,kS17,0,kS18,0,kS19,0,kS20,0,kS22,1,kS23,1,kS24,1,kS25,1,kS26,1,kS27,1,kS28,1,kS29,1,kS30,1,kS31,1,kS32,1,kS33,1,kS34,1,kS35,1,kS36,1,kS37,1,kS38,1,kS39,1,kS40,1,kS41,1,kS42,1,kS43,1,kS44,1,kS45,1,kS46,1,kS47,1,kS48,1,kS49,1,kS50,1,kS51,1,kS52,1,kS53,1,kS54,1,kS55,1,kS56,1,kS57,1,kS58,1,kS59,1,kS60,1,kS61,1,kS62,1,kS63,1,kS64,1,kS65,1,kS66,1,kS67,1,kS68,1,kS69,1,kS70,1,kS71,1,kS72,1,kS73,1,kS74,1,kS75,1,kS76,1,kS77,1,kS78,1,kS79,1,kS80,1,kS81,1,kS82,1,kS83,1,kS84,1,kS85,1,kS86,1,kS87,1,kS88,1,kS89,1,kS90,1,kS91,1,kS92,1,kS93,1,kS94,1,kS95,1,kS96,1,kS97,1,kS98,1,kS99,1,kS100,1,kS101,1,kS102,1,kS103,1,kS104,1,kS105,1,kS106,1,kS107,1,kS108,1,kS109,1,kS110,1,kS111,1,kS112,1,kS113,1,kS114,1,kS115,1,kS116,1,kS117,1,kS118,1,kS119,1,kS120,1,kS121,1,kS122,1,kS123,1,kS124,1,kS125,1,kS126,1,kS127,1,kS128,1,kS129,1,kS130,1,kS131,1,kS132,1,kS133,1,kS134,1,kS135,1,kS136,1,kS137,1,kS138,1,kS139,1,kS140,1,kS141,1,kS142,1,kS143,1,kS144,1,kS145,1,kS146,1,kS147,1,kS148,1,kS149,1,kS150,1,kS151,1,kS152,1,kS153,1,kS154,1,kS155,1,kS156,1,kS157,1,kS158,1,kS159,1,kS160,1,kS161,1,kS162,1,kS163,1,kS164,1,kS165,1,kS166,1,kS167,1,kS168,1,kS169,1,kS170,1,kS171,1,kS172,1,kS173,1,kS174,1,kS175,1,kS176,1,kS177,1,kS178,1,kS179,1,kS180,1,kS181,1,kS182,1,kS183,1,kS184,1,kS185,1,kS186,1,kS187,1,kS188,1,kS189,1,kS190,1,kS191,1,kS192,1,kS193,1,kS194,1,kS195,1,kS196,1,kS197,1,kS198,1,kS199,1,kS200,1,kA1,0,kA2,0,kA3,0,kA4,0,kA5,0,kA6,0,kA7,0,kA8,0,kA9,0,kA10,0,kA11,0,kA12,0,kA13,0,kA14,0,kA15,0,kA16,0,kA17,0,kA18,0,kA19,0,kA20,0"
232
+ return header + ";" + objects_string
233
+
234
+
235
+ def insert_level_into_save(save_path: str, level_name: str, raw_objects_string: str):
236
+ """
237
+ Decrypts the local levels save file, parses the XML, appends/inserts the new level,
238
+ and saves/encrypts it back to CCLocalLevels.dat.
239
+ """
240
+ print(f"Reading and decrypting save file from {save_path}...")
241
+ decrypted_xml = decrypt_save_file(save_path)
242
+
243
+ # Standard clean level string creation
244
+ full_level_data = build_full_level_string(raw_objects_string)
245
+ encrypted_k4 = encrypt_level_string(full_level_data)
246
+
247
+ # Build XML dictionary block for this level
248
+ # Inside CCLocalLevels, the level map contains levels like k_0, k_1 etc under LLM_01 <d>
249
+ # We will search for <k>LLM_01</k> and insert our level dictionary under its children
250
+ print("Parsing save file structure...")
251
+
252
+ # We use regex/string manipulation to insert cleanly to avoid XML namespace/formatting problems
253
+ # Finding the main dict under LLM_01: `<k>LLM_01</k><d><k>_isArr</k><t />`
254
+ match = re.search(r"<k>LLM_01</k><d><k>_isArr</k><t />", decrypted_xml)
255
+ if not match:
256
+ raise ValueError("Could not locate active levels dictionary (LLM_01) in save file.")
257
+
258
+ # Build a beautiful, minimal level dict structure
259
+ new_level_id = f"k_{int(time_hash()) % 100000}"
260
+ new_level_xml = f"""<k>{new_level_id}</k><d><k>kCEK</k><i>4</i><k>k2</k><s>{level_name}</s><k>k3</k><s>Converted MIDI Piano Roll</s><k>k4</k><s>{encrypted_k4}</s><k>k5</k><s>Player</s><k>k13</k><t /><k>k21</k><i>2</i><k>k16</k><i>1</i><k>k80</k><i>1</i></d>"""
261
+
262
+ insert_point = match.end()
263
+ modified_xml = decrypted_xml[:insert_point] + new_level_xml + decrypted_xml[insert_point:]
264
+
265
+ print(f"Successfully inserted '{level_name}' into decrypted XML.")
266
+
267
+ # Re-encrypt
268
+ print("Encrypting save data...")
269
+ encrypted_bytes = encrypt_save_file(modified_xml)
270
+
271
+ # Backup the original CCLocalLevels.dat
272
+ backup_path = save_path + ".bak"
273
+ if not os.path.exists(backup_path):
274
+ os.rename(save_path, backup_path)
275
+ print(f"Created a safe backup of your save file at: {backup_path}")
276
+
277
+ # Write back the newly encrypted save file
278
+ with open(save_path, "wb") as f:
279
+ f.write(encrypted_bytes)
280
+ print("Save file successfully updated and re-encrypted!")
281
+
282
+
283
+ def time_hash() -> str:
284
+ import time
285
+ return str(int(time.time()))
286
+
287
+
288
+ def get_default_save_path() -> str:
289
+ """Returns the default Windows path for CCLocalLevels.dat."""
290
+ if sys.platform == "win32":
291
+ return os.path.join(os.environ["LOCALAPPDATA"], "GeometryDash", "CCLocalLevels.dat")
292
+ elif sys.platform == "darwin":
293
+ return os.path.expanduser("~/Library/Application Support/GeometryDash/CCLocalLevels.dat")
294
+ else:
295
+ return ""
296
+
297
+
298
+ def main():
299
+ parser = argparse.ArgumentParser(description="Convert MIDI file to standard Geometry Dash solid piano roll levels.")
300
+ parser.add_argument("midi", help="Path to the source .mid or .midi file")
301
+ parser.add_argument("-n", "--name", help="Name of the generated level (defaults to MIDI file name)")
302
+ parser.add_argument("-b", "--beats-scale", type=float, default=2.0, help="Blocks per beat/quarter note (default: 2.0)")
303
+ parser.add_argument("-s", "--save-file", help="Path to your CCLocalLevels.dat to inject directly into the game")
304
+ parser.add_argument("-e", "--export-string", action="store_true", help="Print/export the raw level string instead of updating save file")
305
+
306
+ args = parser.parse_args()
307
+
308
+ if not os.path.exists(args.midi):
309
+ print(f"Error: MIDI file '{args.midi}' not found.")
310
+ sys.exit(1)
311
+
312
+ print(f"Parsing MIDI file: {args.midi}...")
313
+ try:
314
+ notes = parse_midi_notes(args.midi)
315
+ except Exception as e:
316
+ print(f"Error parsing MIDI file: {e}")
317
+ sys.exit(1)
318
+
319
+ print(f"Extracted {len(notes)} musical notes successfully.")
320
+
321
+ print("Generating block layout...")
322
+ raw_objects = generate_gd_level_objects(notes, args.beats_scale)
323
+
324
+ level_name = args.name if args.name else os.path.splitext(os.path.basename(args.midi))[0]
325
+
326
+ if args.export_string:
327
+ full_level_data = build_full_level_string(raw_objects)
328
+ encrypted_k4 = encrypt_level_string(full_level_data)
329
+ print("\n--- Encrypted level string (k4 format) ---")
330
+ print(encrypted_k4)
331
+ print("------------------------------------------")
332
+ return
333
+
334
+ # Direct Injection Mode
335
+ save_path = args.save_file if args.save_file else get_default_save_path()
336
+
337
+ if not save_path or not os.path.exists(save_path):
338
+ print("\nCould not automatically locate CCLocalLevels.dat.")
339
+ print("Please provide the correct path to CCLocalLevels.dat using the -s/--save-file option.")
340
+ print("Or run with -e/--export-string to get the raw level data string.")
341
+
342
+ # Still generate a standalone file for the user
343
+ standalone_out = f"{level_name}.gmd"
344
+ full_level_data = build_full_level_string(raw_objects)
345
+ encrypted_k4 = encrypt_level_string(full_level_data)
346
+
347
+ # Build standard gmd plist file
348
+ gmd_content = f"""<?xml version="1.0"?>
349
+ <plist version="1.0" gjver="2.0">
350
+ <dict>
351
+ <key>kCEK</key><i>4</i>
352
+ <key>k2</key><s>{level_name}</s>
353
+ <key>k3</key><s>MIDI converted Piano Roll</s>
354
+ <key>k4</key><s>{encrypted_k4}</s>
355
+ <key>k5</key><s>Player</s>
356
+ </dict>
357
+ </plist>"""
358
+ with open(standalone_out, "w", encoding="utf-8") as f:
359
+ f.write(gmd_content)
360
+ print(f"\nSaved standalone importable level to: {standalone_out}")
361
+ print("You can import this .gmd file using standard GD editors/overlays.")
362
+ else:
363
+ try:
364
+ insert_level_into_save(save_path, level_name, raw_objects)
365
+ except Exception as e:
366
+ print(f"Error inserting level into save file: {e}")
367
+ sys.exit(1)
368
+
369
+
370
+ if __name__ == "__main__":
371
+ main()
test_midi_to_gd.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Unit and Integration Tests for midi_to_gd.py
4
+ """
5
+
6
+ import os
7
+ import unittest
8
+ import zlib
9
+ import base64
10
+ import gzip
11
+ import xml.etree.ElementTree as ET
12
+
13
+ # Import functions from midi_to_gd
14
+ from midi_to_gd import (
15
+ xor_data,
16
+ decrypt_save_file,
17
+ encrypt_save_file,
18
+ decrypt_level_string,
19
+ encrypt_level_string,
20
+ MIDINote,
21
+ generate_gd_level_objects,
22
+ build_full_level_string
23
+ )
24
+
25
+
26
+ class TestGeometryDashMIDICreator(unittest.TestCase):
27
+
28
+ def test_xor_data(self):
29
+ """Test that XOR data function behaves symmetrically."""
30
+ original = b"Hello, Geometry Dash!"
31
+ encrypted = xor_data(original, 11)
32
+ decrypted = xor_data(encrypted, 11)
33
+ self.assertEqual(original, decrypted)
34
+
35
+ def test_level_string_cryptography(self):
36
+ """Test symmetric encryption/decryption of the level string (<k>k4</k> format)."""
37
+ decrypted_original = "kS1,30,kS2,35;1,1,2,300,3,450,21,1;"
38
+ encrypted = encrypt_level_string(decrypted_original)
39
+ decrypted_back = decrypt_level_string(encrypted)
40
+ self.assertEqual(decrypted_original, decrypted_back)
41
+
42
+ def test_midi_to_gd_objects_mapping(self):
43
+ """Test converting MIDINotes to actual Geometry Dash object layout strings."""
44
+ notes = [
45
+ MIDINote(pitch=60, start_time=0.0, end_time=1.0, velocity=100), # 1 beat
46
+ MIDINote(pitch=64, start_time=1.0, end_time=3.0, velocity=100), # 2 beats
47
+ ]
48
+
49
+ # 2 blocks per beat
50
+ raw_objects = generate_gd_level_objects(notes, blocks_per_beat=2.0)
51
+
52
+ # Split objects
53
+ obj_list = [o for o in raw_objects.split(";") if o]
54
+
55
+ # Pitch 60 (C4) and Pitch 64 (E4) are unique pitches. Sorted: [60, 64]
56
+ # pitch 60 is index 0 -> color channel 1, Y coordinate = 450
57
+ # pitch 64 is index 1 -> color channel 2, Y coordinate = 480
58
+
59
+ # Check count of blocks:
60
+ # Note 1: 1 beat duration * 2 blocks/beat = 2 blocks
61
+ # Note 2: 2 beats duration * 2 blocks/beat = 4 blocks
62
+ self.assertEqual(len(obj_list), 6)
63
+
64
+ # Verify first note block format
65
+ first_block = obj_list[0]
66
+ self.assertIn("1,1", first_block) # obj id
67
+ self.assertIn("3,450.0", first_block) # Y coordinate
68
+ self.assertIn("21,1", first_block) # Color Channel 1
69
+
70
+ # Verify note 2 block format
71
+ third_block = obj_list[2]
72
+ self.assertIn("1,1", third_block) # obj id
73
+ self.assertIn("3,480.0", third_block) # Y coordinate
74
+ self.assertIn("21,2", third_block) # Color Channel 2
75
+
76
+ def test_full_level_string_builder(self):
77
+ """Test build_full_level_string combines correctly."""
78
+ raw_objects = "1,1,2,300,3,450,21,1;"
79
+ full_string = build_full_level_string(raw_objects)
80
+ self.assertTrue(full_string.startswith("kS1,30"))
81
+ self.assertTrue(full_string.endswith(raw_objects))
82
+
83
+
84
+ if __name__ == "__main__":
85
+ unittest.main()