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.
@@ -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()