gmd-editor 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gmd_editor-0.1.0/PKG-INFO +151 -0
- gmd_editor-0.1.0/README.md +141 -0
- gmd_editor-0.1.0/gmd_editor/__init__.py +10 -0
- gmd_editor-0.1.0/gmd_editor/codec.py +62 -0
- gmd_editor-0.1.0/gmd_editor/io.py +106 -0
- gmd_editor-0.1.0/gmd_editor/level.py +214 -0
- gmd_editor-0.1.0/gmd_editor/objects.py +297 -0
- gmd_editor-0.1.0/pyproject.toml +16 -0
- gmd_editor-0.1.0/tests/test_gmdlib.py +208 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gmd-editor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Decode, encode, and edit Geometry Dash .gmd level files
|
|
5
|
+
Project-URL: Source, https://github.com/cool101wool/gmd_editor
|
|
6
|
+
License: MIT
|
|
7
|
+
Keywords: gamefiles,geometry-dash,gmd,level
|
|
8
|
+
Requires-Python: >=3.9
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# gmdlib
|
|
12
|
+
|
|
13
|
+
A Python library for decoding, encoding, and editing Geometry Dash `.gmd` level files.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install -e .
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from gmdlib import load, save
|
|
25
|
+
|
|
26
|
+
# Load a .gmd file
|
|
27
|
+
level = load("MyLevel.gmd")
|
|
28
|
+
|
|
29
|
+
print(f"Object count: {level.object_count}")
|
|
30
|
+
print(f"Bounding box: {level.bounding_box()}")
|
|
31
|
+
|
|
32
|
+
# Iterate over objects
|
|
33
|
+
for obj in level:
|
|
34
|
+
print(f"id={obj.id} x={obj.x} y={obj.y}")
|
|
35
|
+
|
|
36
|
+
# Move all objects up by 30 units
|
|
37
|
+
level.transform(lambda o: setattr(o, "y", (o.y or 0) + 30))
|
|
38
|
+
|
|
39
|
+
# Save the modified level
|
|
40
|
+
save(level, "MyLevel_modified.gmd")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## API Reference
|
|
44
|
+
|
|
45
|
+
### `load(path)` / `save(level, path)`
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from gmdlib import load, save
|
|
49
|
+
|
|
50
|
+
level = load("level.gmd")
|
|
51
|
+
save(level, "level_out.gmd")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### `GMDLevel`
|
|
55
|
+
|
|
56
|
+
| Attribute / Method | Description |
|
|
57
|
+
|--------------------|-------------|
|
|
58
|
+
| `level.objects` | `list[GDObject]` — all objects |
|
|
59
|
+
| `level.header` | The level settings string |
|
|
60
|
+
| `level.object_count` | Number of objects |
|
|
61
|
+
| `level.bounding_box()` | `(min_x, min_y, max_x, max_y)` |
|
|
62
|
+
| `level.unique_object_ids()` | Set of GD object IDs used |
|
|
63
|
+
| `level.filter(fn)` | Filter objects by predicate |
|
|
64
|
+
| `level.filter_by_id(id)` | Filter objects by GD object ID |
|
|
65
|
+
| `level.transform(fn)` | Apply a function to every object in-place |
|
|
66
|
+
| `level.add_object(obj)` | Append a new object |
|
|
67
|
+
| `level.remove_objects(fn)` | Remove matching objects; returns count removed |
|
|
68
|
+
| `level.to_level_string()` | Serialise to raw level string |
|
|
69
|
+
| `level.to_gmd_string()` | Serialise back to full .gmd XML |
|
|
70
|
+
|
|
71
|
+
### `GDObject`
|
|
72
|
+
|
|
73
|
+
Named property shortcuts (all gettable and settable):
|
|
74
|
+
|
|
75
|
+
| Property | Key | Type |
|
|
76
|
+
|----------|-----|------|
|
|
77
|
+
| `obj.id` | `"1"` | `int` |
|
|
78
|
+
| `obj.x` | `"2"` | `float` |
|
|
79
|
+
| `obj.y` | `"3"` | `float` |
|
|
80
|
+
| `obj.h_flip` | `"4"` | `bool` |
|
|
81
|
+
| `obj.v_flip` | `"5"` | `bool` |
|
|
82
|
+
| `obj.rotation` | `"6"` | `float` |
|
|
83
|
+
| `obj.scale` | `"36"` | `float` |
|
|
84
|
+
| `obj.groups` | `"57"` | `list[int]` |
|
|
85
|
+
|
|
86
|
+
Raw access for any property:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
obj["21"] # get by numeric key string
|
|
90
|
+
obj["21"] = "3" # set
|
|
91
|
+
obj.get("21", "0") # with default
|
|
92
|
+
obj.remove("21") # delete
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Low-level codec
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from gmdlib.codec import (
|
|
99
|
+
decode_level_string, # base64url+gzip → plain text
|
|
100
|
+
encode_level_string, # plain text → base64url+gzip
|
|
101
|
+
extract_b64, # pull encoded data from .gmd XML
|
|
102
|
+
inject_b64, # put encoded data back into .gmd XML
|
|
103
|
+
fix_padding, # fix GD's broken base64 padding
|
|
104
|
+
)
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Examples
|
|
108
|
+
|
|
109
|
+
### Teleport all objects to a fixed X position
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
level = load("level.gmd")
|
|
113
|
+
level.transform(lambda o: setattr(o, "x", 0.0))
|
|
114
|
+
save(level, "level_out.gmd")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Remove all objects of a specific type
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
level = load("level.gmd")
|
|
121
|
+
removed = level.remove_objects(lambda o: o.id == 8) # remove all spike objects
|
|
122
|
+
print(f"Removed {removed} spikes")
|
|
123
|
+
save(level, "level_out.gmd")
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Create a new object and add it
|
|
127
|
+
|
|
128
|
+
```python
|
|
129
|
+
from gmdlib import load, save
|
|
130
|
+
from gmdlib import GDObject
|
|
131
|
+
|
|
132
|
+
level = load("level.gmd")
|
|
133
|
+
|
|
134
|
+
new_obj = GDObject()
|
|
135
|
+
new_obj.id = 1 # block
|
|
136
|
+
new_obj.x = 300.0
|
|
137
|
+
new_obj.y = 0.0
|
|
138
|
+
level.add_object(new_obj)
|
|
139
|
+
|
|
140
|
+
save(level, "level_out.gmd")
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Work directly with the level string
|
|
144
|
+
|
|
145
|
+
```python
|
|
146
|
+
from gmdlib.level import GMDLevel
|
|
147
|
+
|
|
148
|
+
level = GMDLevel.from_level_string("kA,1;1,1,2,100,3,200;")
|
|
149
|
+
print(level[0].x) # 100.0
|
|
150
|
+
raw = level.to_level_string()
|
|
151
|
+
```
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# gmdlib
|
|
2
|
+
|
|
3
|
+
A Python library for decoding, encoding, and editing Geometry Dash `.gmd` level files.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e .
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from gmdlib import load, save
|
|
15
|
+
|
|
16
|
+
# Load a .gmd file
|
|
17
|
+
level = load("MyLevel.gmd")
|
|
18
|
+
|
|
19
|
+
print(f"Object count: {level.object_count}")
|
|
20
|
+
print(f"Bounding box: {level.bounding_box()}")
|
|
21
|
+
|
|
22
|
+
# Iterate over objects
|
|
23
|
+
for obj in level:
|
|
24
|
+
print(f"id={obj.id} x={obj.x} y={obj.y}")
|
|
25
|
+
|
|
26
|
+
# Move all objects up by 30 units
|
|
27
|
+
level.transform(lambda o: setattr(o, "y", (o.y or 0) + 30))
|
|
28
|
+
|
|
29
|
+
# Save the modified level
|
|
30
|
+
save(level, "MyLevel_modified.gmd")
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## API Reference
|
|
34
|
+
|
|
35
|
+
### `load(path)` / `save(level, path)`
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from gmdlib import load, save
|
|
39
|
+
|
|
40
|
+
level = load("level.gmd")
|
|
41
|
+
save(level, "level_out.gmd")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### `GMDLevel`
|
|
45
|
+
|
|
46
|
+
| Attribute / Method | Description |
|
|
47
|
+
|--------------------|-------------|
|
|
48
|
+
| `level.objects` | `list[GDObject]` — all objects |
|
|
49
|
+
| `level.header` | The level settings string |
|
|
50
|
+
| `level.object_count` | Number of objects |
|
|
51
|
+
| `level.bounding_box()` | `(min_x, min_y, max_x, max_y)` |
|
|
52
|
+
| `level.unique_object_ids()` | Set of GD object IDs used |
|
|
53
|
+
| `level.filter(fn)` | Filter objects by predicate |
|
|
54
|
+
| `level.filter_by_id(id)` | Filter objects by GD object ID |
|
|
55
|
+
| `level.transform(fn)` | Apply a function to every object in-place |
|
|
56
|
+
| `level.add_object(obj)` | Append a new object |
|
|
57
|
+
| `level.remove_objects(fn)` | Remove matching objects; returns count removed |
|
|
58
|
+
| `level.to_level_string()` | Serialise to raw level string |
|
|
59
|
+
| `level.to_gmd_string()` | Serialise back to full .gmd XML |
|
|
60
|
+
|
|
61
|
+
### `GDObject`
|
|
62
|
+
|
|
63
|
+
Named property shortcuts (all gettable and settable):
|
|
64
|
+
|
|
65
|
+
| Property | Key | Type |
|
|
66
|
+
|----------|-----|------|
|
|
67
|
+
| `obj.id` | `"1"` | `int` |
|
|
68
|
+
| `obj.x` | `"2"` | `float` |
|
|
69
|
+
| `obj.y` | `"3"` | `float` |
|
|
70
|
+
| `obj.h_flip` | `"4"` | `bool` |
|
|
71
|
+
| `obj.v_flip` | `"5"` | `bool` |
|
|
72
|
+
| `obj.rotation` | `"6"` | `float` |
|
|
73
|
+
| `obj.scale` | `"36"` | `float` |
|
|
74
|
+
| `obj.groups` | `"57"` | `list[int]` |
|
|
75
|
+
|
|
76
|
+
Raw access for any property:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
obj["21"] # get by numeric key string
|
|
80
|
+
obj["21"] = "3" # set
|
|
81
|
+
obj.get("21", "0") # with default
|
|
82
|
+
obj.remove("21") # delete
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Low-level codec
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from gmdlib.codec import (
|
|
89
|
+
decode_level_string, # base64url+gzip → plain text
|
|
90
|
+
encode_level_string, # plain text → base64url+gzip
|
|
91
|
+
extract_b64, # pull encoded data from .gmd XML
|
|
92
|
+
inject_b64, # put encoded data back into .gmd XML
|
|
93
|
+
fix_padding, # fix GD's broken base64 padding
|
|
94
|
+
)
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Examples
|
|
98
|
+
|
|
99
|
+
### Teleport all objects to a fixed X position
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
level = load("level.gmd")
|
|
103
|
+
level.transform(lambda o: setattr(o, "x", 0.0))
|
|
104
|
+
save(level, "level_out.gmd")
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### Remove all objects of a specific type
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
level = load("level.gmd")
|
|
111
|
+
removed = level.remove_objects(lambda o: o.id == 8) # remove all spike objects
|
|
112
|
+
print(f"Removed {removed} spikes")
|
|
113
|
+
save(level, "level_out.gmd")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Create a new object and add it
|
|
117
|
+
|
|
118
|
+
```python
|
|
119
|
+
from gmdlib import load, save
|
|
120
|
+
from gmdlib import GDObject
|
|
121
|
+
|
|
122
|
+
level = load("level.gmd")
|
|
123
|
+
|
|
124
|
+
new_obj = GDObject()
|
|
125
|
+
new_obj.id = 1 # block
|
|
126
|
+
new_obj.x = 300.0
|
|
127
|
+
new_obj.y = 0.0
|
|
128
|
+
level.add_object(new_obj)
|
|
129
|
+
|
|
130
|
+
save(level, "level_out.gmd")
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Work directly with the level string
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from gmdlib.level import GMDLevel
|
|
137
|
+
|
|
138
|
+
level = GMDLevel.from_level_string("kA,1;1,1,2,100,3,200;")
|
|
139
|
+
print(level[0].x) # 100.0
|
|
140
|
+
raw = level.to_level_string()
|
|
141
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""
|
|
2
|
+
gmdlib - A library for decoding, encoding, and editing Geometry Dash .gmd files.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from .level import GMDLevel
|
|
6
|
+
from .objects import GDObject
|
|
7
|
+
from .io import load, save
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
__all__ = ["GMDLevel", "GDObject", "load", "save"]
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Low-level codec functions for Geometry Dash .gmd base64/gzip encoding.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import gzip
|
|
6
|
+
import base64
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def fix_padding(gmd: str) -> str:
|
|
10
|
+
"""Fix broken base64 padding that GD sometimes writes into .gmd files."""
|
|
11
|
+
gmd = gmd.replace("AAA</s><k>k", "AAA==</s><k>k")
|
|
12
|
+
gmd = gmd.replace("AA</s><k>k", "AA=</s><k>k")
|
|
13
|
+
gmd = gmd.replace("A</s><k>k", "A=</s><k>k")
|
|
14
|
+
return gmd
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract_b64(gmd: str) -> str:
|
|
18
|
+
"""Extract the raw base64-encoded level string from a .gmd file."""
|
|
19
|
+
marker = "k4</k><s>"
|
|
20
|
+
start = gmd.find(marker)
|
|
21
|
+
if start == -1:
|
|
22
|
+
raise ValueError("Could not find level data marker 'k4</k><s>' in .gmd file.")
|
|
23
|
+
start += len(marker)
|
|
24
|
+
end = gmd.find("<", start)
|
|
25
|
+
return gmd[start:end]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def inject_b64(gmd: str, new_b64: str) -> str:
|
|
29
|
+
"""Replace the encoded level data inside a .gmd string with new_b64."""
|
|
30
|
+
marker = "k4</k><s>"
|
|
31
|
+
start = gmd.find(marker)
|
|
32
|
+
if start == -1:
|
|
33
|
+
raise ValueError("Could not find level data marker 'k4</k><s>' in .gmd file.")
|
|
34
|
+
start += len(marker)
|
|
35
|
+
end = gmd.find("<", start)
|
|
36
|
+
return gmd[:start] + new_b64 + gmd[end:]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def decode_level_string(b64url: str) -> str:
|
|
40
|
+
"""
|
|
41
|
+
Decode a GD-format base64url+gzip level string into a plain text level string.
|
|
42
|
+
|
|
43
|
+
GD uses URL-safe base64 (- and _ instead of + and /) and strips padding.
|
|
44
|
+
"""
|
|
45
|
+
b64 = b64url.replace("-", "+").replace("_", "/")
|
|
46
|
+
# Restore padding
|
|
47
|
+
remainder = len(b64) % 4
|
|
48
|
+
if remainder:
|
|
49
|
+
b64 += "=" * (4 - remainder)
|
|
50
|
+
compressed = base64.b64decode(b64)
|
|
51
|
+
return gzip.decompress(compressed).decode("utf-8")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def encode_level_string(level_str: str) -> str:
|
|
55
|
+
"""
|
|
56
|
+
Encode a plain text level string into GD-format base64url+gzip.
|
|
57
|
+
|
|
58
|
+
Compresses with gzip, encodes as URL-safe base64, and strips padding.
|
|
59
|
+
"""
|
|
60
|
+
compressed = gzip.compress(level_str.encode("utf-8"))
|
|
61
|
+
b64 = base64.b64encode(compressed).decode("ascii")
|
|
62
|
+
return b64.replace("+", "-").replace("/", "_").replace("=", "")
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Convenience file I/O functions for .gmd files.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
import os
|
|
7
|
+
from .level import GMDLevel
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load(path: str) -> GMDLevel:
|
|
11
|
+
"""
|
|
12
|
+
Load a .gmd file from *path* and return a decoded GMDLevel.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
path : str
|
|
17
|
+
Path to the .gmd file.
|
|
18
|
+
|
|
19
|
+
Returns
|
|
20
|
+
-------
|
|
21
|
+
GMDLevel
|
|
22
|
+
The decoded level, ready to inspect and edit.
|
|
23
|
+
|
|
24
|
+
Example
|
|
25
|
+
-------
|
|
26
|
+
>>> from gmdlib import load
|
|
27
|
+
>>> level = load("MyLevel.gmd")
|
|
28
|
+
>>> print(level.object_count)
|
|
29
|
+
"""
|
|
30
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
31
|
+
gmd = f.read()
|
|
32
|
+
return GMDLevel.from_gmd_string(gmd)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def save(level: GMDLevel, path: str) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Save a GMDLevel back to a .gmd file at *path*.
|
|
38
|
+
|
|
39
|
+
The level data is re-compressed and injected back into the original
|
|
40
|
+
.gmd XML wrapper. The output path does not need to match the input path,
|
|
41
|
+
so you can easily save a modified copy.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
level : GMDLevel
|
|
46
|
+
The level to save (must have been loaded from a .gmd file).
|
|
47
|
+
path : str
|
|
48
|
+
Destination file path.
|
|
49
|
+
|
|
50
|
+
Example
|
|
51
|
+
-------
|
|
52
|
+
>>> from gmdlib import load, save
|
|
53
|
+
>>> level = load("MyLevel.gmd")
|
|
54
|
+
>>> save(level, "MyLevel_modified.gmd")
|
|
55
|
+
"""
|
|
56
|
+
gmd_str = level.to_gmd_string()
|
|
57
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
58
|
+
f.write(gmd_str)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def save_copy(level: GMDLevel, suffix: str = "_modified") -> str:
|
|
62
|
+
"""
|
|
63
|
+
Convenience wrapper that saves a modified copy next to the original file.
|
|
64
|
+
|
|
65
|
+
Only works when the level was loaded with ``load()``, since the original
|
|
66
|
+
path is embedded in ``GMDLevel._raw_gmd``. Actually, this just generates
|
|
67
|
+
a path from the original -- you must supply an explicit path via ``save()``
|
|
68
|
+
if you need full control.
|
|
69
|
+
|
|
70
|
+
Parameters
|
|
71
|
+
----------
|
|
72
|
+
level : GMDLevel
|
|
73
|
+
Level to save.
|
|
74
|
+
suffix : str
|
|
75
|
+
String appended before the extension (default ``"_modified"``).
|
|
76
|
+
|
|
77
|
+
Returns
|
|
78
|
+
-------
|
|
79
|
+
str
|
|
80
|
+
The path the file was written to.
|
|
81
|
+
|
|
82
|
+
Raises
|
|
83
|
+
------
|
|
84
|
+
ValueError
|
|
85
|
+
If the level has no associated file path.
|
|
86
|
+
"""
|
|
87
|
+
if not hasattr(level, "_source_path") or level._source_path is None: # type: ignore[attr-defined]
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"Level has no associated source path. "
|
|
90
|
+
"Use save(level, path) and supply an explicit path instead."
|
|
91
|
+
)
|
|
92
|
+
src: str = level._source_path # type: ignore[attr-defined]
|
|
93
|
+
base, ext = os.path.splitext(src)
|
|
94
|
+
out_path = base + suffix + ext
|
|
95
|
+
save(level, out_path)
|
|
96
|
+
return out_path
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Patch load to attach source path
|
|
100
|
+
_original_load = load
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load(path: str) -> GMDLevel: # noqa: F811
|
|
104
|
+
level = _original_load(path)
|
|
105
|
+
level._source_path = os.path.abspath(path) # type: ignore[attr-defined]
|
|
106
|
+
return level
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GMDLevel - high-level representation of a decoded Geometry Dash .gmd level.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from typing import Callable, Iterable, Iterator, List, Optional
|
|
7
|
+
|
|
8
|
+
from .codec import (
|
|
9
|
+
fix_padding,
|
|
10
|
+
extract_b64,
|
|
11
|
+
inject_b64,
|
|
12
|
+
decode_level_string,
|
|
13
|
+
encode_level_string,
|
|
14
|
+
)
|
|
15
|
+
from .objects import GDObject
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GMDLevel:
|
|
19
|
+
"""
|
|
20
|
+
A decoded Geometry Dash level loaded from a .gmd file.
|
|
21
|
+
|
|
22
|
+
Attributes
|
|
23
|
+
----------
|
|
24
|
+
header : str
|
|
25
|
+
The level settings/header string (everything before the first object).
|
|
26
|
+
objects : list[GDObject]
|
|
27
|
+
All objects in the level.
|
|
28
|
+
|
|
29
|
+
Examples
|
|
30
|
+
--------
|
|
31
|
+
Load, edit, and save::
|
|
32
|
+
|
|
33
|
+
from gmdlib import load, save
|
|
34
|
+
|
|
35
|
+
level = load("myLevel.gmd")
|
|
36
|
+
|
|
37
|
+
# Move all objects up by 30 units
|
|
38
|
+
for obj in level:
|
|
39
|
+
if obj.y is not None:
|
|
40
|
+
obj.y += 30
|
|
41
|
+
|
|
42
|
+
save(level, "myLevel_modified.gmd")
|
|
43
|
+
|
|
44
|
+
Filter objects by type::
|
|
45
|
+
|
|
46
|
+
spikes = level.filter(lambda o: o.id == 8)
|
|
47
|
+
|
|
48
|
+
Bulk transform::
|
|
49
|
+
|
|
50
|
+
level.transform(lambda o: setattr(o, "y", (o.y or 0) + 30))
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
header: str,
|
|
56
|
+
objects: List[GDObject],
|
|
57
|
+
_raw_gmd: Optional[str] = None,
|
|
58
|
+
) -> None:
|
|
59
|
+
self.header = header
|
|
60
|
+
self.objects = objects
|
|
61
|
+
self._raw_gmd = _raw_gmd # original .gmd XML wrapper (preserved for saving)
|
|
62
|
+
|
|
63
|
+
# ------------------------------------------------------------------
|
|
64
|
+
# Class methods
|
|
65
|
+
# ------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
@classmethod
|
|
68
|
+
def from_gmd_string(cls, gmd: str) -> "GMDLevel":
|
|
69
|
+
"""
|
|
70
|
+
Parse a full .gmd file string into a GMDLevel.
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
gmd : str
|
|
75
|
+
Raw text contents of a .gmd file.
|
|
76
|
+
"""
|
|
77
|
+
gmd = fix_padding(gmd)
|
|
78
|
+
b64 = extract_b64(gmd)
|
|
79
|
+
level_str = decode_level_string(b64)
|
|
80
|
+
header, objects = _parse_level_string(level_str)
|
|
81
|
+
return cls(header=header, objects=objects, _raw_gmd=gmd)
|
|
82
|
+
|
|
83
|
+
@classmethod
|
|
84
|
+
def from_level_string(cls, level_str: str) -> "GMDLevel":
|
|
85
|
+
"""
|
|
86
|
+
Parse a raw (decompressed) GD level string directly.
|
|
87
|
+
|
|
88
|
+
Parameters
|
|
89
|
+
----------
|
|
90
|
+
level_str : str
|
|
91
|
+
Plain text level string (semicolon-separated objects).
|
|
92
|
+
"""
|
|
93
|
+
header, objects = _parse_level_string(level_str)
|
|
94
|
+
return cls(header=header, objects=objects, _raw_gmd=None)
|
|
95
|
+
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
# Serialisation
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def to_level_string(self) -> str:
|
|
101
|
+
"""Reconstruct the plain text level string from current state."""
|
|
102
|
+
parts = [self.header] + [obj.to_string() for obj in self.objects]
|
|
103
|
+
return ";".join(parts) + ";"
|
|
104
|
+
|
|
105
|
+
def to_gmd_string(self) -> str:
|
|
106
|
+
"""
|
|
107
|
+
Reconstruct the full .gmd file string with the modified level injected.
|
|
108
|
+
|
|
109
|
+
Raises
|
|
110
|
+
------
|
|
111
|
+
ValueError
|
|
112
|
+
If this GMDLevel was not created from a .gmd file (no wrapper XML).
|
|
113
|
+
"""
|
|
114
|
+
if self._raw_gmd is None:
|
|
115
|
+
raise ValueError(
|
|
116
|
+
"No .gmd wrapper available. "
|
|
117
|
+
"Use GMDLevel.from_gmd_string() to load from a .gmd file, "
|
|
118
|
+
"or call to_level_string() to get only the level data."
|
|
119
|
+
)
|
|
120
|
+
level_str = self.to_level_string()
|
|
121
|
+
new_b64 = encode_level_string(level_str)
|
|
122
|
+
return inject_b64(self._raw_gmd, new_b64)
|
|
123
|
+
|
|
124
|
+
# ------------------------------------------------------------------
|
|
125
|
+
# Iteration / filtering / bulk transforms
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
def __iter__(self) -> Iterator[GDObject]:
|
|
129
|
+
return iter(self.objects)
|
|
130
|
+
|
|
131
|
+
def __len__(self) -> int:
|
|
132
|
+
return len(self.objects)
|
|
133
|
+
|
|
134
|
+
def __getitem__(self, index: int) -> GDObject:
|
|
135
|
+
return self.objects[index]
|
|
136
|
+
|
|
137
|
+
def filter(self, predicate: Callable[[GDObject], bool]) -> List[GDObject]:
|
|
138
|
+
"""Return a list of objects matching *predicate*."""
|
|
139
|
+
return [obj for obj in self.objects if predicate(obj)]
|
|
140
|
+
|
|
141
|
+
def filter_by_id(self, obj_id: int) -> List[GDObject]:
|
|
142
|
+
"""Return all objects with the given GD object ID."""
|
|
143
|
+
return [obj for obj in self.objects if obj.id == obj_id]
|
|
144
|
+
|
|
145
|
+
def transform(self, fn: Callable[[GDObject], None]) -> None:
|
|
146
|
+
"""
|
|
147
|
+
Apply *fn* to every object in-place.
|
|
148
|
+
|
|
149
|
+
Parameters
|
|
150
|
+
----------
|
|
151
|
+
fn : Callable[[GDObject], None]
|
|
152
|
+
A function that receives each GDObject and mutates it.
|
|
153
|
+
|
|
154
|
+
Example
|
|
155
|
+
-------
|
|
156
|
+
>>> level.transform(lambda o: setattr(o, "y", (o.y or 0) + 30))
|
|
157
|
+
"""
|
|
158
|
+
for obj in self.objects:
|
|
159
|
+
fn(obj)
|
|
160
|
+
|
|
161
|
+
def add_object(self, obj: GDObject) -> None:
|
|
162
|
+
"""Append a new object to the level."""
|
|
163
|
+
self.objects.append(obj)
|
|
164
|
+
|
|
165
|
+
def remove_objects(self, predicate: Callable[[GDObject], bool]) -> int:
|
|
166
|
+
"""
|
|
167
|
+
Remove all objects matching *predicate*.
|
|
168
|
+
|
|
169
|
+
Returns
|
|
170
|
+
-------
|
|
171
|
+
int
|
|
172
|
+
Number of objects removed.
|
|
173
|
+
"""
|
|
174
|
+
before = len(self.objects)
|
|
175
|
+
self.objects = [obj for obj in self.objects if not predicate(obj)]
|
|
176
|
+
return before - len(self.objects)
|
|
177
|
+
|
|
178
|
+
# ------------------------------------------------------------------
|
|
179
|
+
# Statistics / introspection
|
|
180
|
+
# ------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
@property
|
|
183
|
+
def object_count(self) -> int:
|
|
184
|
+
return len(self.objects)
|
|
185
|
+
|
|
186
|
+
def bounding_box(self):
|
|
187
|
+
"""
|
|
188
|
+
Return the axis-aligned bounding box of all objects as
|
|
189
|
+
(min_x, min_y, max_x, max_y), or None if there are no objects.
|
|
190
|
+
"""
|
|
191
|
+
xs = [obj.x for obj in self.objects if obj.x is not None]
|
|
192
|
+
ys = [obj.y for obj in self.objects if obj.y is not None]
|
|
193
|
+
if not xs:
|
|
194
|
+
return None
|
|
195
|
+
return (min(xs), min(ys), max(xs), max(ys))
|
|
196
|
+
|
|
197
|
+
def unique_object_ids(self) -> set[int]:
|
|
198
|
+
"""Return the set of unique GD object IDs used in this level."""
|
|
199
|
+
return {obj.id for obj in self.objects if obj.id is not None}
|
|
200
|
+
|
|
201
|
+
def __repr__(self) -> str:
|
|
202
|
+
return f"GMDLevel(objects={self.object_count})"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
# Internal helpers
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
def _parse_level_string(level_str: str):
|
|
210
|
+
"""Split a GD level string into (header, [GDObject, ...])."""
|
|
211
|
+
parts = level_str.split(";")
|
|
212
|
+
header = parts[0]
|
|
213
|
+
objects = [GDObject.from_string(p) for p in parts[1:] if p]
|
|
214
|
+
return header, objects
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GDObject - represents a single Geometry Dash level object with named property access.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
from typing import Any, Dict, Iterator, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
# Well-known GD property IDs → human-readable names
|
|
10
|
+
_PROP_NAMES: Dict[str, str] = {
|
|
11
|
+
"1": "id",
|
|
12
|
+
"2": "x",
|
|
13
|
+
"3": "y",
|
|
14
|
+
"4": "h_flip",
|
|
15
|
+
"5": "v_flip",
|
|
16
|
+
"6": "rotation",
|
|
17
|
+
"7": "red",
|
|
18
|
+
"8": "green",
|
|
19
|
+
"9": "blue",
|
|
20
|
+
"10": "duration",
|
|
21
|
+
"11": "touch_triggered",
|
|
22
|
+
"12": "secret_coin_id",
|
|
23
|
+
"13": "twoplayermode",
|
|
24
|
+
"14": "passthrough",
|
|
25
|
+
"15": "hide",
|
|
26
|
+
"17": "move_x",
|
|
27
|
+
"18": "move_y",
|
|
28
|
+
"19": "ease_type",
|
|
29
|
+
"20": "text",
|
|
30
|
+
"21": "color_channel",
|
|
31
|
+
"22": "main_color",
|
|
32
|
+
"23": "detail_color",
|
|
33
|
+
"24": "group_parent",
|
|
34
|
+
"25": "opacity",
|
|
35
|
+
"28": "editor_layer",
|
|
36
|
+
"29": "high_detail",
|
|
37
|
+
"30": "unknown_30",
|
|
38
|
+
"31": "dont_fade",
|
|
39
|
+
"32": "dont_enter",
|
|
40
|
+
"35": "texture_id",
|
|
41
|
+
"36": "scale",
|
|
42
|
+
"41": "group_ids",
|
|
43
|
+
"43": "hsv_enabled",
|
|
44
|
+
"44": "hsv_value",
|
|
45
|
+
"45": "fade_in",
|
|
46
|
+
"46": "hold",
|
|
47
|
+
"47": "fade_out",
|
|
48
|
+
"48": "pulse_mode",
|
|
49
|
+
"49": "copied_color",
|
|
50
|
+
"50": "copy_opacity",
|
|
51
|
+
"51": "editor_layer2",
|
|
52
|
+
"52": "spawn_triggered",
|
|
53
|
+
"57": "link_id",
|
|
54
|
+
"58": "reversed",
|
|
55
|
+
"59": "locked_to_camera",
|
|
56
|
+
"60": "activate_group",
|
|
57
|
+
"62": "stop_when_exit",
|
|
58
|
+
"63": "animation_id",
|
|
59
|
+
"64": "count",
|
|
60
|
+
"65": "subtract_count",
|
|
61
|
+
"66": "pickup_mode",
|
|
62
|
+
"67": "item_id",
|
|
63
|
+
"68": "hold_mode",
|
|
64
|
+
"69": "interval",
|
|
65
|
+
"71": "toggle_mode",
|
|
66
|
+
"72": "follow_y_offset",
|
|
67
|
+
"73": "follow_y_speed",
|
|
68
|
+
"74": "follow_y_delay",
|
|
69
|
+
"75": "follow_y_max_speed",
|
|
70
|
+
"76": "follow_y_reversed",
|
|
71
|
+
"84": "orderpriority",
|
|
72
|
+
"85": "unknown_85",
|
|
73
|
+
"86": "multi_trigger",
|
|
74
|
+
"87": "color_type",
|
|
75
|
+
"88": "yellow_teleport",
|
|
76
|
+
"89": "activate_on_exit",
|
|
77
|
+
"94": "dynamic_block",
|
|
78
|
+
"95": "block_b",
|
|
79
|
+
"96": "glow_disabled",
|
|
80
|
+
"97": "custom_respawn",
|
|
81
|
+
"98": "no_retry",
|
|
82
|
+
"99": "rotate_degrees",
|
|
83
|
+
"100": "times_360",
|
|
84
|
+
"101": "lock_object_rotation",
|
|
85
|
+
"105": "target_pos_coordinates",
|
|
86
|
+
"106": "use_target",
|
|
87
|
+
"107": "target_pos_id",
|
|
88
|
+
"108": "editor_disable",
|
|
89
|
+
"110": "no_touch",
|
|
90
|
+
"111": "transform_scale_x",
|
|
91
|
+
"112": "transform_scale_y",
|
|
92
|
+
"114": "override_player_1",
|
|
93
|
+
"116": "enter_channel",
|
|
94
|
+
"117": "scale_x",
|
|
95
|
+
"118": "scale_y",
|
|
96
|
+
"121": "enter_effect_id",
|
|
97
|
+
"125": "particle_data",
|
|
98
|
+
"128": "unlock_item_id",
|
|
99
|
+
"129": "unlock_item_type",
|
|
100
|
+
"132": "camera_static",
|
|
101
|
+
"133": "camera_mode",
|
|
102
|
+
"134": "camera_edge_value",
|
|
103
|
+
"135": "camera_zoom",
|
|
104
|
+
"138": "unknown_138",
|
|
105
|
+
"141": "unknown_141",
|
|
106
|
+
"142": "unknown_142",
|
|
107
|
+
"143": "unknown_143",
|
|
108
|
+
"155": "unknown_155",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
# Reverse mapping: name → id string
|
|
112
|
+
_NAME_TO_ID: Dict[str, str] = {v: k for k, v in _PROP_NAMES.items()}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class GDObject:
|
|
116
|
+
"""
|
|
117
|
+
A single Geometry Dash level object.
|
|
118
|
+
|
|
119
|
+
Properties are stored as raw string key/value pairs matching the GD format.
|
|
120
|
+
Named access is provided for well-known properties (x, y, id, rotation, etc.).
|
|
121
|
+
|
|
122
|
+
Examples
|
|
123
|
+
--------
|
|
124
|
+
>>> obj = GDObject.from_string("1,1,2,300,3,150,6,90")
|
|
125
|
+
>>> obj.x
|
|
126
|
+
300.0
|
|
127
|
+
>>> obj.y
|
|
128
|
+
150.0
|
|
129
|
+
>>> obj.rotation
|
|
130
|
+
90.0
|
|
131
|
+
>>> obj["2"] # raw access
|
|
132
|
+
'300'
|
|
133
|
+
>>> obj.set("2", 400) # raw set
|
|
134
|
+
>>> obj.x
|
|
135
|
+
400.0
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def __init__(self, props: Optional[Dict[str, str]] = None) -> None:
|
|
139
|
+
self._props: Dict[str, str] = props or {}
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------------------------------
|
|
142
|
+
# Serialisation
|
|
143
|
+
# ------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
@classmethod
|
|
146
|
+
def from_string(cls, obj_str: str) -> "GDObject":
|
|
147
|
+
"""Parse a GD object string (comma-separated key/value pairs)."""
|
|
148
|
+
parts = obj_str.split(",")
|
|
149
|
+
props: Dict[str, str] = {}
|
|
150
|
+
for i in range(0, len(parts) - 1, 2):
|
|
151
|
+
props[parts[i]] = parts[i + 1]
|
|
152
|
+
return cls(props)
|
|
153
|
+
|
|
154
|
+
def to_string(self) -> str:
|
|
155
|
+
"""Serialise back to a GD object string."""
|
|
156
|
+
out: list[str] = []
|
|
157
|
+
for k, v in self._props.items():
|
|
158
|
+
out.append(k)
|
|
159
|
+
out.append(str(v))
|
|
160
|
+
return ",".join(out)
|
|
161
|
+
|
|
162
|
+
def __repr__(self) -> str:
|
|
163
|
+
obj_id = self._props.get("1", "?")
|
|
164
|
+
x = self._props.get("2", "?")
|
|
165
|
+
y = self._props.get("3", "?")
|
|
166
|
+
return f"GDObject(id={obj_id}, x={x}, y={y})"
|
|
167
|
+
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
# Raw dict-style access
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
def __getitem__(self, key: str) -> str:
|
|
173
|
+
return self._props[key]
|
|
174
|
+
|
|
175
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
176
|
+
self._props[key] = str(value)
|
|
177
|
+
|
|
178
|
+
def __contains__(self, key: str) -> bool:
|
|
179
|
+
return key in self._props
|
|
180
|
+
|
|
181
|
+
def __iter__(self) -> Iterator[str]:
|
|
182
|
+
return iter(self._props)
|
|
183
|
+
|
|
184
|
+
def get(self, key: str, default: Any = None) -> Optional[str]:
|
|
185
|
+
return self._props.get(key, default)
|
|
186
|
+
|
|
187
|
+
def set(self, key: str, value: Any) -> None:
|
|
188
|
+
self._props[key] = str(value)
|
|
189
|
+
|
|
190
|
+
def remove(self, key: str) -> None:
|
|
191
|
+
"""Remove a property by its numeric key string."""
|
|
192
|
+
self._props.pop(key, None)
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def props(self) -> Dict[str, str]:
|
|
196
|
+
"""Direct access to the underlying property dict."""
|
|
197
|
+
return self._props
|
|
198
|
+
|
|
199
|
+
# ------------------------------------------------------------------
|
|
200
|
+
# Named property helpers (float where numeric, else str)
|
|
201
|
+
# ------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
def _get_float(self, key: str) -> Optional[float]:
|
|
204
|
+
val = self._props.get(key)
|
|
205
|
+
if val is None:
|
|
206
|
+
return None
|
|
207
|
+
try:
|
|
208
|
+
return float(val)
|
|
209
|
+
except ValueError:
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
def _set_numeric(self, key: str, value: Any) -> None:
|
|
213
|
+
self._props[key] = str(value)
|
|
214
|
+
|
|
215
|
+
# Geometry
|
|
216
|
+
@property
|
|
217
|
+
def id(self) -> Optional[int]:
|
|
218
|
+
v = self._get_float("1")
|
|
219
|
+
return int(v) if v is not None else None
|
|
220
|
+
|
|
221
|
+
@id.setter
|
|
222
|
+
def id(self, value: int) -> None:
|
|
223
|
+
self._set_numeric("1", value)
|
|
224
|
+
|
|
225
|
+
@property
|
|
226
|
+
def x(self) -> Optional[float]:
|
|
227
|
+
return self._get_float("2")
|
|
228
|
+
|
|
229
|
+
@x.setter
|
|
230
|
+
def x(self, value: float) -> None:
|
|
231
|
+
self._set_numeric("2", value)
|
|
232
|
+
|
|
233
|
+
@property
|
|
234
|
+
def y(self) -> Optional[float]:
|
|
235
|
+
return self._get_float("3")
|
|
236
|
+
|
|
237
|
+
@y.setter
|
|
238
|
+
def y(self, value: float) -> None:
|
|
239
|
+
self._set_numeric("3", value)
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def rotation(self) -> Optional[float]:
|
|
243
|
+
return self._get_float("6")
|
|
244
|
+
|
|
245
|
+
@rotation.setter
|
|
246
|
+
def rotation(self, value: float) -> None:
|
|
247
|
+
self._set_numeric("6", value)
|
|
248
|
+
|
|
249
|
+
@property
|
|
250
|
+
def scale(self) -> Optional[float]:
|
|
251
|
+
return self._get_float("36")
|
|
252
|
+
|
|
253
|
+
@scale.setter
|
|
254
|
+
def scale(self, value: float) -> None:
|
|
255
|
+
self._set_numeric("36", value)
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
def h_flip(self) -> bool:
|
|
259
|
+
return self._props.get("4", "0") == "1"
|
|
260
|
+
|
|
261
|
+
@h_flip.setter
|
|
262
|
+
def h_flip(self, value: bool) -> None:
|
|
263
|
+
self._props["4"] = "1" if value else "0"
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def v_flip(self) -> bool:
|
|
267
|
+
return self._props.get("5", "0") == "1"
|
|
268
|
+
|
|
269
|
+
@v_flip.setter
|
|
270
|
+
def v_flip(self, value: bool) -> None:
|
|
271
|
+
self._props["5"] = "1" if value else "0"
|
|
272
|
+
|
|
273
|
+
# Groups
|
|
274
|
+
@property
|
|
275
|
+
def groups(self) -> list[int]:
|
|
276
|
+
"""Return list of group IDs (property 57 is space-separated)."""
|
|
277
|
+
raw = self._props.get("57", "")
|
|
278
|
+
if not raw:
|
|
279
|
+
return []
|
|
280
|
+
try:
|
|
281
|
+
return [int(g) for g in raw.split(".") if g]
|
|
282
|
+
except ValueError:
|
|
283
|
+
return []
|
|
284
|
+
|
|
285
|
+
@groups.setter
|
|
286
|
+
def groups(self, value: list[int]) -> None:
|
|
287
|
+
self._props["57"] = ".".join(str(g) for g in value)
|
|
288
|
+
|
|
289
|
+
# Editor layer
|
|
290
|
+
@property
|
|
291
|
+
def editor_layer(self) -> Optional[int]:
|
|
292
|
+
v = self._get_float("20")
|
|
293
|
+
return int(v) if v is not None else None
|
|
294
|
+
|
|
295
|
+
@editor_layer.setter
|
|
296
|
+
def editor_layer(self, value: int) -> None:
|
|
297
|
+
self._set_numeric("20", value)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gmd-editor"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Decode, encode, and edit Geometry Dash .gmd level files"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["geometry-dash", "gmd", "level", "gamefiles"]
|
|
13
|
+
dependencies = []
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Source = "https://github.com/cool101wool/gmd_editor"
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Tests for gmdlib."""
|
|
2
|
+
|
|
3
|
+
import gzip
|
|
4
|
+
import base64
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from gmdlib.codec import (
|
|
8
|
+
fix_padding,
|
|
9
|
+
decode_level_string,
|
|
10
|
+
encode_level_string,
|
|
11
|
+
extract_b64,
|
|
12
|
+
inject_b64,
|
|
13
|
+
)
|
|
14
|
+
from gmdlib.objects import GDObject
|
|
15
|
+
from gmdlib.level import GMDLevel
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ---------------------------------------------------------------------------
|
|
19
|
+
# Helpers
|
|
20
|
+
# ---------------------------------------------------------------------------
|
|
21
|
+
|
|
22
|
+
def _make_b64(level_str: str) -> str:
|
|
23
|
+
compressed = gzip.compress(level_str.encode("utf-8"))
|
|
24
|
+
b64 = base64.b64encode(compressed).decode("ascii")
|
|
25
|
+
return b64.replace("+", "-").replace("/", "_").replace("=", "")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _wrap_gmd(b64: str) -> str:
|
|
29
|
+
return f'<k>k4</k><s>{b64}</s><k>k5</k>'
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
# Codec
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
class TestCodec:
|
|
37
|
+
def test_roundtrip(self):
|
|
38
|
+
original = "kA,1;1,1,2,300,3,150;"
|
|
39
|
+
encoded = encode_level_string(original)
|
|
40
|
+
decoded = decode_level_string(encoded)
|
|
41
|
+
assert decoded == original
|
|
42
|
+
|
|
43
|
+
def test_url_safe_chars(self):
|
|
44
|
+
# Encoded string must not contain + / =
|
|
45
|
+
encoded = encode_level_string("hello world")
|
|
46
|
+
assert "+" not in encoded
|
|
47
|
+
assert "/" not in encoded
|
|
48
|
+
assert "=" not in encoded
|
|
49
|
+
|
|
50
|
+
def test_fix_padding(self):
|
|
51
|
+
s = "AAA</s><k>k"
|
|
52
|
+
fixed = fix_padding(s)
|
|
53
|
+
assert "AAA==</s><k>k" in fixed
|
|
54
|
+
|
|
55
|
+
def test_extract_b64(self):
|
|
56
|
+
b64 = _make_b64("header;1,1,2,100,3,200;")
|
|
57
|
+
gmd = _wrap_gmd(b64)
|
|
58
|
+
assert extract_b64(gmd) == b64
|
|
59
|
+
|
|
60
|
+
def test_extract_b64_missing_raises(self):
|
|
61
|
+
with pytest.raises(ValueError):
|
|
62
|
+
extract_b64("<k>k5</k><s>something</s>")
|
|
63
|
+
|
|
64
|
+
def test_inject_b64(self):
|
|
65
|
+
b64 = _make_b64("header;")
|
|
66
|
+
gmd = _wrap_gmd(b64)
|
|
67
|
+
new_b64 = _make_b64("header;1,1,2,99,3,99;")
|
|
68
|
+
result = inject_b64(gmd, new_b64)
|
|
69
|
+
assert new_b64 in result
|
|
70
|
+
assert b64 not in result
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
# GDObject
|
|
75
|
+
# ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
class TestGDObject:
|
|
78
|
+
def test_from_string_basic(self):
|
|
79
|
+
obj = GDObject.from_string("1,1,2,300,3,150")
|
|
80
|
+
assert obj["1"] == "1"
|
|
81
|
+
assert obj["2"] == "300"
|
|
82
|
+
assert obj["3"] == "150"
|
|
83
|
+
|
|
84
|
+
def test_named_properties(self):
|
|
85
|
+
obj = GDObject.from_string("1,8,2,300.0,3,150.5,6,45.0")
|
|
86
|
+
assert obj.id == 8
|
|
87
|
+
assert obj.x == 300.0
|
|
88
|
+
assert obj.y == 150.5
|
|
89
|
+
assert obj.rotation == 45.0
|
|
90
|
+
|
|
91
|
+
def test_set_named_property(self):
|
|
92
|
+
obj = GDObject.from_string("1,1,2,0,3,0")
|
|
93
|
+
obj.x = 500.0
|
|
94
|
+
obj.y = 200.0
|
|
95
|
+
assert obj["2"] == "500.0"
|
|
96
|
+
assert obj["3"] == "200.0"
|
|
97
|
+
|
|
98
|
+
def test_to_string_roundtrip(self):
|
|
99
|
+
raw = "1,1,2,300,3,150,6,0"
|
|
100
|
+
obj = GDObject.from_string(raw)
|
|
101
|
+
assert obj.to_string() == raw
|
|
102
|
+
|
|
103
|
+
def test_h_flip(self):
|
|
104
|
+
obj = GDObject.from_string("1,1,4,1")
|
|
105
|
+
assert obj.h_flip is True
|
|
106
|
+
obj.h_flip = False
|
|
107
|
+
assert obj["4"] == "0"
|
|
108
|
+
|
|
109
|
+
def test_missing_property_returns_none(self):
|
|
110
|
+
obj = GDObject.from_string("1,1")
|
|
111
|
+
assert obj.x is None
|
|
112
|
+
assert obj.rotation is None
|
|
113
|
+
|
|
114
|
+
def test_contains(self):
|
|
115
|
+
obj = GDObject.from_string("1,1,2,100")
|
|
116
|
+
assert "1" in obj
|
|
117
|
+
assert "99" not in obj
|
|
118
|
+
|
|
119
|
+
def test_remove(self):
|
|
120
|
+
obj = GDObject.from_string("1,1,2,100,3,200")
|
|
121
|
+
obj.remove("2")
|
|
122
|
+
assert "2" not in obj
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ---------------------------------------------------------------------------
|
|
126
|
+
# GMDLevel
|
|
127
|
+
# ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
def _make_level_str():
|
|
130
|
+
return "kA,1;1,1,2,100,3,200;1,2,2,300,3,400;1,3,2,500,3,600;"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _make_gmd():
|
|
134
|
+
b64 = _make_b64(_make_level_str())
|
|
135
|
+
return _wrap_gmd(b64)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class TestGMDLevel:
|
|
139
|
+
def test_from_level_string(self):
|
|
140
|
+
level = GMDLevel.from_level_string(_make_level_str())
|
|
141
|
+
assert level.object_count == 3
|
|
142
|
+
|
|
143
|
+
def test_from_gmd_string(self):
|
|
144
|
+
level = GMDLevel.from_gmd_string(_make_gmd())
|
|
145
|
+
assert level.object_count == 3
|
|
146
|
+
|
|
147
|
+
def test_iter(self):
|
|
148
|
+
level = GMDLevel.from_level_string(_make_level_str())
|
|
149
|
+
ids = [obj.id for obj in level]
|
|
150
|
+
assert ids == [1, 2, 3]
|
|
151
|
+
|
|
152
|
+
def test_filter(self):
|
|
153
|
+
level = GMDLevel.from_level_string(_make_level_str())
|
|
154
|
+
result = level.filter(lambda o: o.id == 2)
|
|
155
|
+
assert len(result) == 1
|
|
156
|
+
assert result[0].x == 300.0
|
|
157
|
+
|
|
158
|
+
def test_filter_by_id(self):
|
|
159
|
+
level = GMDLevel.from_level_string(_make_level_str())
|
|
160
|
+
assert len(level.filter_by_id(1)) == 1
|
|
161
|
+
|
|
162
|
+
def test_transform(self):
|
|
163
|
+
level = GMDLevel.from_level_string(_make_level_str())
|
|
164
|
+
level.transform(lambda o: setattr(o, "y", (o.y or 0) + 30))
|
|
165
|
+
assert level[0].y == 230.0
|
|
166
|
+
assert level[1].y == 430.0
|
|
167
|
+
|
|
168
|
+
def test_add_object(self):
|
|
169
|
+
level = GMDLevel.from_level_string(_make_level_str())
|
|
170
|
+
new_obj = GDObject.from_string("1,10,2,0,3,0")
|
|
171
|
+
level.add_object(new_obj)
|
|
172
|
+
assert level.object_count == 4
|
|
173
|
+
|
|
174
|
+
def test_remove_objects(self):
|
|
175
|
+
level = GMDLevel.from_level_string(_make_level_str())
|
|
176
|
+
removed = level.remove_objects(lambda o: o.id == 2)
|
|
177
|
+
assert removed == 1
|
|
178
|
+
assert level.object_count == 2
|
|
179
|
+
|
|
180
|
+
def test_bounding_box(self):
|
|
181
|
+
level = GMDLevel.from_level_string(_make_level_str())
|
|
182
|
+
bb = level.bounding_box()
|
|
183
|
+
assert bb == (100.0, 200.0, 500.0, 600.0)
|
|
184
|
+
|
|
185
|
+
def test_unique_object_ids(self):
|
|
186
|
+
level = GMDLevel.from_level_string(_make_level_str())
|
|
187
|
+
assert level.unique_object_ids() == {1, 2, 3}
|
|
188
|
+
|
|
189
|
+
def test_to_level_string_roundtrip(self):
|
|
190
|
+
level_str = _make_level_str()
|
|
191
|
+
level = GMDLevel.from_level_string(level_str)
|
|
192
|
+
assert level.to_level_string() == level_str
|
|
193
|
+
|
|
194
|
+
def test_to_gmd_string(self):
|
|
195
|
+
gmd = _make_gmd()
|
|
196
|
+
level = GMDLevel.from_gmd_string(gmd)
|
|
197
|
+
# Modify something
|
|
198
|
+
level.transform(lambda o: setattr(o, "y", (o.y or 0) + 30))
|
|
199
|
+
new_gmd = level.to_gmd_string()
|
|
200
|
+
# Should still be loadable
|
|
201
|
+
reloaded = GMDLevel.from_gmd_string(new_gmd)
|
|
202
|
+
assert reloaded.object_count == 3
|
|
203
|
+
assert reloaded[0].y == 230.0
|
|
204
|
+
|
|
205
|
+
def test_to_gmd_string_no_wrapper_raises(self):
|
|
206
|
+
level = GMDLevel.from_level_string(_make_level_str())
|
|
207
|
+
with pytest.raises(ValueError):
|
|
208
|
+
level.to_gmd_string()
|