Spaces:
Paused
Paused
Upload 3 files
Browse files- GEOMETRY_DASH_MIDI_PLAN.md +96 -0
- midi_to_gd.py +371 -0
- 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()
|