gmd-editor 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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"]
gmd_editor/codec.py ADDED
@@ -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("=", "")
gmd_editor/io.py ADDED
@@ -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
gmd_editor/level.py ADDED
@@ -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
gmd_editor/objects.py ADDED
@@ -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,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,8 @@
1
+ gmd_editor/__init__.py,sha256=nD9aXELA8kAdwPuZnbmLYci7cPlp3wrMcG13C_uvkE0,249
2
+ gmd_editor/codec.py,sha256=_5ZCO-NkCVOarb5i_ej9lQAxmxp0FKTNmfmKFIPIMkI,1997
3
+ gmd_editor/io.py,sha256=qyzUfE_pk3FaRxkc9HjrPekodJYyvaVwntgf3JNpln4,2818
4
+ gmd_editor/level.py,sha256=V1y0_ZtgkRF7yCU2Pi-sJcfYzbkPCcR7c9w2lW-IIOw,6670
5
+ gmd_editor/objects.py,sha256=O5L30GcCQoP1TZFKZEig2TfJDjYNJR_1GnwPDMkM01A,8069
6
+ gmd_editor-0.1.0.dist-info/METADATA,sha256=XYjWVlUPRGtbzP8KP3DRh5JRUdYjzfs-pVJ0wzOkYtA,3683
7
+ gmd_editor-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ gmd_editor-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any