koro 2.0.0rc2__py3-none-any.whl → 2.0.1__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
koro/slot/xml.py CHANGED
@@ -1,783 +1,845 @@
1
- # mypy: disable-error-code=union-attr
2
-
3
- from collections.abc import Iterator, Sequence
4
- from io import StringIO
5
- from itertools import chain
6
- from typing import Final
7
- from xml.etree.ElementTree import Element, fromstring
8
-
9
- from ..stage import EditUser, Stage, Theme
10
- from ..stage.model import DecorationModel, DeviceModel, PartModel
11
- from ..stage.part import (
12
- Ant,
13
- BasePart,
14
- BlinkingTile,
15
- Bumper,
16
- Cannon,
17
- ConveyorBelt,
18
- DashTunnel,
19
- Drawbridge,
20
- Fan,
21
- FixedSpeedDevice,
22
- Gear,
23
- Goal,
24
- GreenCrystal,
25
- KororinCapsule,
26
- Magnet,
27
- MagnetSegment,
28
- MagnifyingGlass,
29
- MelodyTile,
30
- MovementTiming,
31
- MovingCurve,
32
- MovingTile,
33
- Part,
34
- Press,
35
- ProgressMarker,
36
- Punch,
37
- Scissors,
38
- SeesawBlock,
39
- SizeTunnel,
40
- SlidingTile,
41
- Speed,
42
- Spring,
43
- Start,
44
- Thorn,
45
- TimedDevice,
46
- ToyTrain,
47
- TrainTrack,
48
- Turntable,
49
- UpsideDownBall,
50
- UpsideDownStageDevice,
51
- Walls,
52
- Warp,
53
- )
54
- from .file import FileSlot
55
-
56
- __all__ = ["XmlSlot"]
57
-
58
-
59
- class XmlSlot(FileSlot):
60
- __slots__ = ()
61
-
62
- @staticmethod
63
- def deserialize(data: bytes) -> Stage:
64
- """Behavior is undefined when passed invalid stage data"""
65
-
66
- def get_values(element: Element, /, tag: str) -> Sequence[str]:
67
- return element.find(tag).text.strip().split()
68
-
69
- def get_pos_rot(element: Element, /) -> Iterator[float]:
70
- return chain(
71
- map(float, get_values(element, "pos")),
72
- map(float, get_values(element, "rot")),
73
- )
74
-
75
- root: Final[Element] = fromstring(
76
- data.decode("shift_jis", "xmlcharrefreplace").replace(
77
- '<?xml version="1.0" encoding="SHIFT_JIS"?>',
78
- '<?xml version="1.0"?><body>',
79
- )
80
- + "</body>"
81
- )
82
- editinfo: Final[Element] = root.find("EDITINFO") # type: ignore[assignment]
83
- output: Final[Stage] = Stage(
84
- (),
85
- edit_user=EditUser(int(editinfo.find("EDITUSER").text.strip())),
86
- theme=Theme(int(editinfo.find("THEME").text.strip())),
87
- tilt_lock=bool(int(editinfo.find("LOCK").text.strip())),
88
- )
89
- groups: Final[dict[int, dict[int, Element]]] = {}
90
- for elem in root.find("STAGEDATA"):
91
- match elem.tag:
92
- case "EDIT_LIGHT" | "EDIT_BG_NORMAL":
93
- continue
94
- case "EDIT_MAP_NORMAL":
95
- output.add(
96
- Part(
97
- *get_pos_rot(elem),
98
- shape=PartModel(int(get_values(elem, "model")[1])),
99
- )
100
- )
101
- case "EDIT_MAP_EXT":
102
- output.add(
103
- Part(
104
- *get_pos_rot(elem),
105
- shape=DecorationModel(int(get_values(elem, "model")[1])),
106
- )
107
- )
108
- case "EDIT_GIM_START":
109
- output.add(Start(*get_pos_rot(elem)))
110
- case "EDIT_GIM_GOAL":
111
- output.add(Goal(*get_pos_rot(elem)))
112
- case "EDIT_GIM_NORMAL":
113
- if (
114
- elem.find("group") is None
115
- or elem.find("group").text != " 0 -1 "
116
- ):
117
- match DeviceModel(int(get_values(elem, "model")[1])):
118
- case DeviceModel.Crystal:
119
- output.add(
120
- ProgressMarker(
121
- *get_pos_rot(elem),
122
- progress=int(get_values(elem, "hook")[0]) * 2 + 1, # type: ignore[arg-type]
123
- )
124
- )
125
- case DeviceModel.Respawn:
126
- output.add(
127
- ProgressMarker(
128
- *get_pos_rot(elem),
129
- progress=int(get_values(elem, "hook")[0]) * 2 + 2, # type: ignore[arg-type]
130
- )
131
- )
132
- case DeviceModel.MovingTile10x10:
133
- output.add(
134
- MovingTile(
135
- *get_pos_rot(elem),
136
- **dict(
137
- zip(
138
- ("dest_x", "dest_y", "dest_z"),
139
- map(float, get_values(elem, "anmmov1")),
140
- )
141
- ), # type: ignore[arg-type]
142
- shape=PartModel.Tile10x10,
143
- speed=float(get_values(elem, "anmspeed")[0]),
144
- )
145
- )
146
- case DeviceModel.MovingTile20x20:
147
- output.add(
148
- MovingTile(
149
- *get_pos_rot(elem),
150
- **dict(
151
- zip(
152
- ("dest_x", "dest_y", "dest_z"),
153
- map(float, get_values(elem, "anmmov1")),
154
- )
155
- ), # type: ignore[arg-type]
156
- shape=PartModel.Tile20x20,
157
- speed=float(get_values(elem, "anmspeed")[0]),
158
- walls=Walls(int(get_values(elem, "hook")[1])),
159
- )
160
- )
161
- case DeviceModel.MovingTile30x30:
162
- output.add(
163
- MovingTile(
164
- *get_pos_rot(elem),
165
- **dict(
166
- zip(
167
- ("dest_x", "dest_y", "dest_z"),
168
- map(float, get_values(elem, "anmmov1")),
169
- )
170
- ), # type: ignore[arg-type]
171
- shape=PartModel.TileA30x30,
172
- speed=float(get_values(elem, "anmspeed")[0]),
173
- walls=Walls(int(get_values(elem, "hook")[1])),
174
- )
175
- )
176
- case DeviceModel.MovingTile90x90A:
177
- output.add(
178
- MovingTile(
179
- *get_pos_rot(elem),
180
- **dict(
181
- zip(
182
- ("dest_x", "dest_y", "dest_z"),
183
- map(float, get_values(elem, "anmmov1")),
184
- )
185
- ), # type: ignore[arg-type]
186
- shape=PartModel.Tile90x90,
187
- speed=float(get_values(elem, "anmspeed")[0]),
188
- )
189
- )
190
- case DeviceModel.MovingTile90x90B:
191
- output.add(
192
- MovingTile(
193
- *get_pos_rot(elem),
194
- **dict(
195
- zip(
196
- ("dest_x", "dest_y", "dest_z"),
197
- map(float, get_values(elem, "anmmov1")),
198
- )
199
- ), # type: ignore[arg-type]
200
- shape=PartModel.HoleB90x90,
201
- speed=float(get_values(elem, "anmspeed")[0]),
202
- )
203
- )
204
- case DeviceModel.MovingTile10x10Switch:
205
- output.add(
206
- MovingTile(
207
- *get_pos_rot(elem),
208
- **dict(
209
- zip(
210
- ("dest_x", "dest_y", "dest_z"),
211
- map(float, get_values(elem, "anmmov1")),
212
- )
213
- ), # type: ignore[arg-type]
214
- shape=PartModel.Tile10x10,
215
- speed=float(get_values(elem, "anmspeed")[0]),
216
- switch=True,
217
- )
218
- )
219
- case DeviceModel.MovingTile20x20Switch:
220
- output.add(
221
- MovingTile(
222
- *get_pos_rot(elem),
223
- **dict(
224
- zip(
225
- ("dest_x", "dest_y", "dest_z"),
226
- map(float, get_values(elem, "anmmov1")),
227
- )
228
- ), # type: ignore[arg-type]
229
- shape=PartModel.Tile20x20,
230
- speed=float(get_values(elem, "anmspeed")[0]),
231
- switch=True,
232
- walls=Walls(int(get_values(elem, "hook")[1])),
233
- )
234
- )
235
- case DeviceModel.MovingTile30x30Switch:
236
- output.add(
237
- MovingTile(
238
- *get_pos_rot(elem),
239
- **dict(
240
- zip(
241
- ("dest_x", "dest_y", "dest_z"),
242
- map(float, get_values(elem, "anmmov1")),
243
- )
244
- ), # type: ignore[arg-type]
245
- shape=PartModel.TileA30x30,
246
- speed=float(get_values(elem, "anmspeed")[0]),
247
- switch=True,
248
- walls=Walls(int(get_values(elem, "hook")[1])),
249
- )
250
- )
251
- case DeviceModel.MovingTile90x90ASwitch:
252
- output.add(
253
- MovingTile(
254
- *get_pos_rot(elem),
255
- **dict(
256
- zip(
257
- ("dest_x", "dest_y", "dest_z"),
258
- map(float, get_values(elem, "anmmov1")),
259
- )
260
- ), # type: ignore[arg-type]
261
- shape=PartModel.Tile90x90,
262
- speed=float(get_values(elem, "anmspeed")[0]),
263
- switch=True,
264
- )
265
- )
266
- case DeviceModel.MovingTile90x90BSwitch:
267
- output.add(
268
- MovingTile(
269
- *get_pos_rot(elem),
270
- **dict(
271
- zip(
272
- ("dest_x", "dest_y", "dest_z"),
273
- map(float, get_values(elem, "anmmov1")),
274
- )
275
- ), # type: ignore[arg-type]
276
- shape=PartModel.HoleB90x90,
277
- speed=float(get_values(elem, "anmspeed")[0]),
278
- switch=True,
279
- )
280
- )
281
- case DeviceModel.MovingFunnelPipe:
282
- output.add(
283
- MovingTile(
284
- *get_pos_rot(elem),
285
- **dict(
286
- zip(
287
- ("dest_x", "dest_y", "dest_z"),
288
- map(float, get_values(elem, "anmmov1")),
289
- )
290
- ), # type: ignore[arg-type]
291
- shape=PartModel.FunnelPipe,
292
- speed=float(get_values(elem, "anmspeed")[0]),
293
- )
294
- )
295
- case DeviceModel.MovingStraightPipe:
296
- output.add(
297
- MovingTile(
298
- *get_pos_rot(elem),
299
- **dict(
300
- zip(
301
- ("dest_x", "dest_y", "dest_z"),
302
- map(float, get_values(elem, "anmmov1")),
303
- )
304
- ), # type: ignore[arg-type]
305
- shape=PartModel.StraightPipe,
306
- speed=float(get_values(elem, "anmspeed")[0]),
307
- )
308
- )
309
- case DeviceModel.MovingCurveS:
310
- output.add(
311
- MovingCurve(
312
- *get_pos_rot(elem),
313
- shape=PartModel.CurveS,
314
- speed=Speed(int(get_values(elem, "sts")[0])),
315
- )
316
- )
317
- case DeviceModel.MovingCurveM:
318
- output.add(
319
- MovingCurve(
320
- *get_pos_rot(elem),
321
- shape=PartModel.CurveM,
322
- speed=Speed(int(get_values(elem, "sts")[0])),
323
- )
324
- )
325
- case DeviceModel.MovingCurveL:
326
- output.add(
327
- MovingCurve(
328
- *get_pos_rot(elem),
329
- shape=PartModel.CurveL,
330
- speed=Speed(int(get_values(elem, "sts")[0])),
331
- )
332
- )
333
- case DeviceModel.SlidingTile:
334
- output.add(SlidingTile(*get_pos_rot(elem)))
335
- case DeviceModel.ConveyorBelt:
336
- output.add(
337
- ConveyorBelt(
338
- *get_pos_rot(elem),
339
- reversing=get_values(elem, "sts")[0] == "39",
340
- )
341
- )
342
- case DeviceModel.DashTunnelA:
343
- output.add(
344
- DashTunnel(
345
- *get_pos_rot(elem),
346
- shape=DeviceModel.DashTunnelA,
347
- )
348
- )
349
- case DeviceModel.DashTunnelB:
350
- output.add(
351
- DashTunnel(
352
- *get_pos_rot(elem),
353
- shape=DeviceModel.DashTunnelB,
354
- )
355
- )
356
- case DeviceModel.SeesawLBlock:
357
- output.add(
358
- SeesawBlock(
359
- *get_pos_rot(elem),
360
- shape=DeviceModel.SeesawLBlock,
361
- )
362
- )
363
- case DeviceModel.SeesawIBlock:
364
- output.add(
365
- SeesawBlock(
366
- *get_pos_rot(elem),
367
- shape=DeviceModel.SeesawIBlock,
368
- )
369
- )
370
- case DeviceModel.AutoSeesawLBlock:
371
- output.add(
372
- SeesawBlock(
373
- *get_pos_rot(elem),
374
- auto=True,
375
- shape=DeviceModel.SeesawLBlock,
376
- )
377
- )
378
- case DeviceModel.AutoSeesawIBlock:
379
- output.add(
380
- SeesawBlock(
381
- *get_pos_rot(elem),
382
- auto=True,
383
- shape=DeviceModel.SeesawIBlock,
384
- )
385
- )
386
- case DeviceModel.Cannon:
387
- output.add(Cannon(*get_pos_rot(elem)))
388
- case DeviceModel.Drawbridge:
389
- output.add(Drawbridge(*get_pos_rot(elem)))
390
- case DeviceModel.Turntable:
391
- output.add(
392
- Turntable(
393
- *get_pos_rot(elem),
394
- speed=Speed(int(get_values(elem, "sts")[0])),
395
- )
396
- )
397
- case DeviceModel.Bumper:
398
- output.add(Bumper(*get_pos_rot(elem)))
399
- case DeviceModel.PowerfulBumper:
400
- output.add(Bumper(*get_pos_rot(elem), powerful=True))
401
- case DeviceModel.Thorn:
402
- output.add(Thorn(*get_pos_rot(elem)))
403
- case DeviceModel.Gear:
404
- output.add(
405
- Gear(
406
- *get_pos_rot(elem),
407
- speed=Speed(int(get_values(elem, "sts")[0])),
408
- )
409
- )
410
- case DeviceModel.Fan:
411
- output.add(Fan(*get_pos_rot(elem)))
412
- case DeviceModel.PowerfulFan:
413
- output.add(
414
- Fan(
415
- *get_pos_rot(elem),
416
- wind_pattern=DeviceModel.PowerfulFan,
417
- )
418
- )
419
- case DeviceModel.TimerFan:
420
- output.add(
421
- Fan(
422
- *get_pos_rot(elem),
423
- wind_pattern=DeviceModel.TimerFan,
424
- )
425
- )
426
- case DeviceModel.Spring:
427
- output.add(Spring(*get_pos_rot(elem)))
428
- case DeviceModel.Punch:
429
- output.add(
430
- Punch(
431
- *get_pos_rot(elem),
432
- timing=MovementTiming(
433
- int(get_values(elem, "sts")[0])
434
- ),
435
- )
436
- )
437
- case DeviceModel.Press:
438
- output.add(
439
- Press(
440
- *get_pos_rot(elem),
441
- timing=MovementTiming(
442
- int(get_values(elem, "sts")[0])
443
- ),
444
- )
445
- )
446
- case DeviceModel.Scissors:
447
- output.add(
448
- Scissors(
449
- *get_pos_rot(elem),
450
- timing=MovementTiming(
451
- int(get_values(elem, "sts")[0])
452
- ),
453
- )
454
- )
455
- case DeviceModel.MagnifyingGlass:
456
- output.add(MagnifyingGlass(*get_pos_rot(elem)))
457
- case DeviceModel.UpsideDownStageDevice:
458
- output.add(UpsideDownStageDevice(*get_pos_rot(elem)))
459
- case DeviceModel.UpsideDownBall:
460
- output.add(UpsideDownBall(*get_pos_rot(elem)))
461
- case DeviceModel.SmallTunnel:
462
- output.add(
463
- SizeTunnel(
464
- *get_pos_rot(elem), size=DeviceModel.SmallTunnel
465
- )
466
- )
467
- case DeviceModel.BigTunnel:
468
- output.add(
469
- SizeTunnel(
470
- *get_pos_rot(elem), size=DeviceModel.BigTunnel
471
- )
472
- )
473
- case DeviceModel.BlinkingTile:
474
- output.add(
475
- BlinkingTile(
476
- *get_pos_rot(elem),
477
- timing=MovementTiming(
478
- int(get_values(elem, "sts")[0])
479
- ),
480
- )
481
- )
482
- case DeviceModel.KororinCapsule:
483
- output.add(KororinCapsule(*get_pos_rot(elem)))
484
- case DeviceModel.GreenCrystal:
485
- output.add(GreenCrystal(*get_pos_rot(elem)))
486
- case DeviceModel.Ant:
487
- output.add(Ant(*get_pos_rot(elem)))
488
- case model if model.name.startswith("MelodyTile"):
489
- output.add(MelodyTile(*get_pos_rot(elem), note=model)) # type: ignore[arg-type]
490
- else:
491
- groups.setdefault(int(get_values(elem, "group")[0]), {})[
492
- int(get_values(elem, "group")[1])
493
- ] = elem
494
- for group in groups.values():
495
- match DeviceModel(int(get_values(group[0], "model")[1])):
496
- case DeviceModel.EndMagnet:
497
- m: Magnet = Magnet()
498
- for _, elem in sorted(group.items()):
499
- m.append(
500
- MagnetSegment(
501
- *get_pos_rot(elem),
502
- shape=DeviceModel(int(get_values(elem, "model")[1])), # type: ignore[arg-type]
503
- )
504
- )
505
- output.add(m)
506
- case DeviceModel.ToyTrain:
507
- t: ToyTrain = ToyTrain(*get_pos_rot(group[0]))
508
- for i, elem in sorted(group.items()):
509
- if i:
510
- t.append(
511
- TrainTrack(
512
- *get_pos_rot(elem),
513
- shape=DeviceModel(int(get_values(elem, "model")[1])), # type: ignore[arg-type]
514
- )
515
- )
516
- output.add(t)
517
- case DeviceModel.Warp:
518
- output.add(
519
- Warp(
520
- *get_pos_rot(group[0]),
521
- **dict(
522
- zip(
523
- ("dest_x", "dest_y", "dest_z"),
524
- map(float, get_values(group[0], "anmmov1")),
525
- )
526
- ),
527
- **dict(
528
- zip(
529
- (
530
- "return_x_pos",
531
- "return_y_pos",
532
- "return_z_pos",
533
- "return_x_rot",
534
- "return_y_rotation",
535
- "return_z_rotation",
536
- ),
537
- get_pos_rot(group[1]),
538
- strict=True,
539
- )
540
- ),
541
- **dict(
542
- zip(
543
- ("return_dest_x", "return_dest_y", "return_dest_z"),
544
- map(float, get_values(group[1], "anmmov1")),
545
- )
546
- ),
547
- ) # type: ignore[misc]
548
- )
549
- return output
550
-
551
- @staticmethod
552
- def serialize(stage: Stage, /) -> bytes:
553
- def minify(value: float, /) -> str:
554
- """Removes the decimal point from floats representing integers."""
555
- return str(int(value) if value.is_integer() else value)
556
-
557
- def serialize_numbers(*values: float) -> str:
558
- """Does not include leading or trailing spaces."""
559
- return " ".join(minify(value) for value in values)
560
-
561
- def anmtype(device: BasePart, /) -> DeviceModel:
562
- if isinstance(device, ProgressMarker):
563
- return (
564
- DeviceModel.Crystal if device.progress % 2 else DeviceModel.Respawn
565
- )
566
- elif isinstance(device, MovingTile):
567
- match device.shape:
568
- case PartModel.Tile10x10:
569
- return (
570
- DeviceModel.MovingTile10x10Switch
571
- if device.switch
572
- else DeviceModel.MovingTile10x10
573
- )
574
- case PartModel.Tile20x20:
575
- return (
576
- DeviceModel.MovingTile20x20Switch
577
- if device.switch
578
- else DeviceModel.MovingTile20x20
579
- )
580
- case PartModel.TileA30x30:
581
- return (
582
- DeviceModel.MovingTile30x30Switch
583
- if device.switch
584
- else DeviceModel.MovingTile30x30
585
- )
586
- case PartModel.TileA30x90:
587
- return (
588
- DeviceModel.MovingTile30x90Switch
589
- if device.switch
590
- else DeviceModel.MovingTile30x90
591
- )
592
- case PartModel.Tile90x90:
593
- return (
594
- DeviceModel.MovingTile90x90ASwitch
595
- if device.switch
596
- else DeviceModel.MovingTile90x90A
597
- )
598
- case PartModel.HoleB90x90:
599
- return (
600
- DeviceModel.MovingTile90x90BSwitch
601
- if device.switch
602
- else DeviceModel.MovingTile90x90B
603
- )
604
- case PartModel.FunnelPipe:
605
- return DeviceModel.MovingFunnelPipe
606
- case PartModel.StraightPipe:
607
- return DeviceModel.MovingStraightPipe
608
- elif isinstance(device, MovingCurve):
609
- match device.shape:
610
- case PartModel.CurveS:
611
- return DeviceModel.MovingCurveS
612
- case PartModel.CurveM:
613
- return DeviceModel.MovingCurveM
614
- case PartModel.CurveL:
615
- return DeviceModel.MovingCurveL
616
- elif isinstance(device, SlidingTile):
617
- return DeviceModel.SlidingTile
618
- elif isinstance(device, ConveyorBelt):
619
- return DeviceModel.ConveyorBelt
620
- elif isinstance(device, DashTunnel):
621
- return device.shape
622
- elif isinstance(device, SeesawBlock):
623
- if device.auto:
624
- match device.shape:
625
- case DeviceModel.SeesawLBlock:
626
- return DeviceModel.AutoSeesawLBlock
627
- case DeviceModel.SeesawIBlock:
628
- return DeviceModel.AutoSeesawIBlock
629
- else:
630
- return device.shape
631
- elif isinstance(device, Cannon):
632
- return DeviceModel.Cannon
633
- elif isinstance(device, Drawbridge):
634
- return DeviceModel.Drawbridge
635
- elif isinstance(device, Turntable):
636
- return DeviceModel.Turntable
637
- elif isinstance(device, Bumper):
638
- return (
639
- DeviceModel.PowerfulBumper
640
- if device.powerful
641
- else DeviceModel.Bumper
642
- )
643
- elif isinstance(device, Thorn):
644
- return DeviceModel.Thorn
645
- elif isinstance(device, Gear):
646
- return DeviceModel.Gear
647
- elif isinstance(device, Fan):
648
- return device.wind_pattern
649
- elif isinstance(device, Spring):
650
- return DeviceModel.Spring
651
- elif isinstance(device, Punch):
652
- return DeviceModel.Punch
653
- elif isinstance(device, Press):
654
- return DeviceModel.Press
655
- elif isinstance(device, Scissors):
656
- return DeviceModel.Scissors
657
- elif isinstance(device, MagnifyingGlass):
658
- return DeviceModel.MagnifyingGlass
659
- elif isinstance(device, UpsideDownStageDevice):
660
- return DeviceModel.UpsideDownStageDevice
661
- elif isinstance(device, UpsideDownBall):
662
- return DeviceModel.UpsideDownBall
663
- elif isinstance(device, SizeTunnel):
664
- return device.size
665
- elif isinstance(device, BlinkingTile):
666
- return DeviceModel.BlinkingTile
667
- elif isinstance(device, MelodyTile):
668
- return device.note
669
- elif isinstance(device, KororinCapsule):
670
- return DeviceModel.KororinCapsule
671
- elif isinstance(device, GreenCrystal):
672
- return DeviceModel.GreenCrystal
673
- elif isinstance(device, Ant):
674
- return DeviceModel.Ant
675
- else:
676
- raise ValueError(f"part {device!r} does not have a known anmtype")
677
-
678
- def device_data(device: BasePart, /) -> str:
679
- if isinstance(device, ProgressMarker):
680
- return f"<hook> {(device._progress - 1) // 2} 0 </hook>"
681
- elif isinstance(device, MovingTile):
682
- anmmov: Final[str] = (
683
- f"<anmspd> {minify(device.speed)} 0 </anmspeed><anmmov0> {serialize_numbers(device.x_pos, device.y_pos, device.z_pos)} </anmmov0><anmmov1> {serialize_numbers(device.dest_x, device.dest_y, device.dest_z)} </anmmov1>"
684
- )
685
- if device.walls:
686
- match device.shape:
687
- case PartModel.Tile20x20:
688
- return f"<hook> {DeviceModel.MovingTile20x20Wall.value} {device.walls.value} </hook>{anmmov}"
689
- case PartModel.TileA30x30:
690
- return f"<hook> {DeviceModel.MovingTile30x30Wall.value} {device.walls.value} </hook>{anmmov}"
691
- return anmmov
692
- else:
693
- return ""
694
-
695
- def sts(part: BasePart, /) -> int:
696
- if isinstance(part, FixedSpeedDevice):
697
- return part.speed.value
698
- elif isinstance(part, ConveyorBelt):
699
- return 39 if part.reversing else 23
700
- elif isinstance(part, TimedDevice):
701
- return part.timing.value
702
- else:
703
- return 7
704
-
705
- with StringIO(
706
- f'<?xml version="1.0" encoding="SHIFT_JIS"?><EDITINFO><THEME> {stage.theme.value} </THEME><LOCK> {int(stage.tilt_lock)} </LOCK><EDITUSER> {stage.edit_user.value} </EDITUSER></EDITINFO><STAGEDATA><EDIT_BG_NORMAL><model> "EBB_{stage.theme.value:02}.bin 0 </model></EDIT_BG_NORMAL>'
707
- ) as output:
708
- group: int = 1
709
- for part in stage:
710
- if isinstance(part, Magnet):
711
- for i, segment in enumerate(part):
712
- output.write(
713
- f'<EDIT_GIM_NORMAL><model> "EGB_{stage.theme.value:02}.bin" {segment.shape} </model><pos> {serialize_numbers(segment.x_pos, segment.y_pos, segment.z_pos)} </pos><rot> {serialize_numbers(segment.x_rot, segment.y_rot, segment.z_rot)} </rot><sts> 7 </sts><group> {group} {i} </group></EDIT_GIM_NORMAL>'
714
- )
715
- group += 1
716
- elif isinstance(part, ToyTrain):
717
- output.write(
718
- f'<EDIT_GIM_NORMAL><model> "EGB_{stage.theme.value:02}.bin" {DeviceModel.ToyTrain.value} </model><pos> {serialize_numbers(part.x_pos, part.y_pos, part.z_pos)} </pos><rot> {part.x_rot, part.y_rot, part.z_rot} </rot><sts> 7 </sts><group> {group} 0 </group></EDIT_GIM_NORMAL>'
719
- )
720
- for i, track in enumerate(part, 1):
721
- output.write(
722
- f'<EDIT_GIM_NORMAL><model> "EGB_{stage.theme.value:02}.bin" {track.shape} </model><pos> {serialize_numbers(track.x_pos, track.y_pos, track.z_pos)} </pos><rot> {serialize_numbers(track.x_rot, track.y_rot, track.z_rot)} </rot><sts> 7 </sts><group> {group} {i} </group></EDIT_GIM_NORMAL>'
723
- )
724
- group += 1
725
- elif isinstance(part, Warp):
726
- output.write(
727
- f'<EDIT_GIM_NORMAL><model> "EGB_{stage.theme.value:02}.bin" {DeviceModel.Warp.value} </model><pos> {serialize_numbers(part.x_pos, part.y_pos, part.z_pos)} </pos><rot> {part.x_rot, part.y_rot, part.z_rot} </rot><sts> 7 </sts><anmmov0> {serialize_numbers(part.dest_x, part.dest_y, part.dest_z)} </anmmov0><group> {group} 0 </group></EDIT_GIM_NORMAL><EDIT_GIM_NORMAL><model> "EGB_{stage.theme.value:02}.bin" {DeviceModel.Warp.value} </model><pos> {serialize_numbers(part.return_x_pos, part.return_y_pos, part.return_z_pos)} </pos><rot> {part.return_x_rot, part.return_y_rot, part.return_z_rot} </rot><sts> 7 </sts><anmmov0> {serialize_numbers(part.return_dest_x, part.return_dest_y, part.return_dest_z)} </anmmov0><group> {group} 0 </group></EDIT_GIM_NORMAL>'
728
- )
729
- group += 1
730
- else:
731
- if isinstance(part, Start):
732
- output.write("<EDIT_GIM_START>")
733
- elif isinstance(part, Goal):
734
- output.write("<EDIT_GIM_GOAL>")
735
- elif isinstance(part, Part):
736
- if isinstance(part.shape, DecorationModel):
737
- output.write("<EDIT_MAP_EXT>")
738
- else:
739
- output.write("<EDIT_MAP_NORMAL>")
740
- else:
741
- output.write("<EDIT_GIM_NORMAL>")
742
- if isinstance(part, Part):
743
- if isinstance(part.shape, DecorationModel):
744
- output.write(
745
- f'<model> "EME_{stage.theme.value:02}.bin" {part.shape.value} </model>'
746
- )
747
- else:
748
- output.write(
749
- f'<model> "EMB_{stage.theme.value:02}.bin" {part.shape.value} </model>'
750
- )
751
- elif isinstance(part, Start):
752
- output.write(
753
- f'<model> "EGB_{stage.theme.value:02}.bin" 0 </model>'
754
- )
755
- elif isinstance(part, Goal):
756
- output.write(
757
- f'<model> "EGB_{stage.theme.value:02}.bin" 1 </model>'
758
- )
759
- else:
760
- output.write(
761
- f'<model> "EGB_{stage.theme.value:02}.bin" {anmtype(part).value} </model>'
762
- )
763
- output.write(
764
- f"<pos> {serialize_numbers(part.x_pos, part.y_pos, part.z_pos)} </pos><rot> {part.x_rot, part.y_rot, part.z_rot} </rot><sts> {sts(part)} </sts>"
765
- )
766
- try:
767
- output.write(f"<anmtype> {anmtype(part).value} </anmtype>")
768
- except ValueError:
769
- pass
770
- output.write(device_data(part))
771
- if isinstance(part, Start):
772
- output.write("</EDIT_GIM_START>")
773
- elif isinstance(part, Goal):
774
- output.write("</EDIT_GIM_GOAL>")
775
- elif isinstance(part, Part):
776
- if isinstance(part.shape, DecorationModel):
777
- output.write("</EDIT_MAP_EXT>")
778
- else:
779
- output.write("</EDIT_MAP_NORMAL>")
780
- else:
781
- output.write("</EDIT_GIM_NORMAL>")
782
- output.write("</STAGEDATA>")
783
- return output.getvalue().encode("shift_jis", "xmlcharrefreplace")
1
+ from collections.abc import Iterator, Sequence
2
+ from io import StringIO
3
+ from itertools import chain
4
+ from os import SEEK_END
5
+ from typing import Final
6
+ from xml.etree.ElementTree import Element, fromstring
7
+
8
+ from ..stage import EditUser, Stage, Theme
9
+ from ..stage.model import DecorationModel, DeviceModel, PartModel
10
+ from ..stage.part import (
11
+ Ant,
12
+ BasePart,
13
+ BlinkingTile,
14
+ Bumper,
15
+ TextBox,
16
+ Cannon,
17
+ ConveyorBelt,
18
+ DashTunnel,
19
+ Drawbridge,
20
+ Fan,
21
+ FixedSpeedDevice,
22
+ Gear,
23
+ Goal,
24
+ GreenCrystal,
25
+ KororinCapsule,
26
+ Magnet,
27
+ MagnetSegment,
28
+ MagnifyingGlass,
29
+ MelodyTile,
30
+ MovementTiming,
31
+ MovingCurve,
32
+ MovingTile,
33
+ Part,
34
+ Press,
35
+ ProgressMarker,
36
+ Punch,
37
+ Scissors,
38
+ SeesawBlock,
39
+ SizeTunnel,
40
+ SlidingTile,
41
+ Speed,
42
+ Spring,
43
+ Start,
44
+ Thorn,
45
+ TimedDevice,
46
+ ToyTrain,
47
+ TrainTrack,
48
+ Turntable,
49
+ UpsideDownBall,
50
+ UpsideDownStageDevice,
51
+ Walls,
52
+ Warp,
53
+ )
54
+ from .file import FileSlot
55
+
56
+ __all__ = ["XmlSlot"]
57
+
58
+
59
+ class XmlSlot(FileSlot):
60
+ __slots__ = ()
61
+
62
+ @staticmethod
63
+ def deserialize(data: bytes) -> Stage:
64
+ """Behavior is undefined when passed invalid stage data"""
65
+
66
+ def get_values(element: Element, /, tag: str) -> Sequence[str]:
67
+ return element.find(tag).text.strip().split() # type: ignore[union-attr]
68
+
69
+ def get_pos_rot(element: Element, /) -> Iterator[float]:
70
+ return chain(
71
+ map(float, get_values(element, "pos")),
72
+ map(float, get_values(element, "rot")),
73
+ )
74
+
75
+ root: Final[Element] = fromstring(
76
+ data.decode("shift_jis", "xmlcharrefreplace").replace(
77
+ '<?xml version="1.0" encoding="SHIFT_JIS"?>',
78
+ '<?xml version="1.0"?>\n<body>',
79
+ )
80
+ + "</body>"
81
+ )
82
+ editinfo: Final[Element] = root.find("EDITINFO") # type: ignore[assignment]
83
+ output: Final[Stage] = Stage(
84
+ (),
85
+ edit_user=(
86
+ EditUser.PROTECTED
87
+ if editinfo.find("EDITUSER") is None
88
+ else EditUser(int(editinfo.find("EDITUSER").text.strip())) # type: ignore[union-attr]
89
+ ),
90
+ theme=Theme(int(editinfo.find("THEME").text.strip())), # type: ignore[union-attr]
91
+ tilt_lock=False if editinfo.find("LOCK") is None else bool(int(editinfo.find("LOCK").text.strip())), # type: ignore[union-attr]
92
+ )
93
+ groups: Final[dict[int, dict[int, Element]]] = {}
94
+ for elem in root.find("STAGEDATA") or ():
95
+ match elem.tag:
96
+ case "EDIT_LIGHT" | "EDIT_BG_NORMAL":
97
+ continue
98
+ case "EDIT_MAP_NORMAL":
99
+ output.add(
100
+ Part(
101
+ *get_pos_rot(elem),
102
+ shape=PartModel(int(get_values(elem, "model")[1])),
103
+ )
104
+ )
105
+ case "EDIT_MAP_EXT":
106
+ output.add(
107
+ Part(
108
+ *get_pos_rot(elem),
109
+ shape=DecorationModel(int(get_values(elem, "model")[1])),
110
+ )
111
+ )
112
+ case "EDIT_GIM_START":
113
+ output.add(Start(*get_pos_rot(elem)))
114
+ case "EDIT_GIM_GOAL":
115
+ output.add(Goal(*get_pos_rot(elem)))
116
+ case "EDIT_GIM_NORMAL":
117
+ match DeviceModel(int(get_values(elem, "model")[1])):
118
+ case DeviceModel.Crystal:
119
+ output.add(
120
+ ProgressMarker(
121
+ *get_pos_rot(elem),
122
+ progress=int(get_values(elem, "hook")[0]) * 2 + 1, # type: ignore[arg-type]
123
+ )
124
+ )
125
+ case DeviceModel.Respawn:
126
+ output.add(
127
+ ProgressMarker(
128
+ *get_pos_rot(elem),
129
+ progress=int(get_values(elem, "hook")[0]) * 2 + 2, # type: ignore[arg-type]
130
+ )
131
+ )
132
+ case DeviceModel.MovingTile10x10:
133
+ output.add(
134
+ MovingTile(
135
+ *get_pos_rot(elem),
136
+ **dict(
137
+ zip(
138
+ ("dest_x", "dest_y", "dest_z"),
139
+ map(float, get_values(elem, "anmmov1")),
140
+ )
141
+ ), # type: ignore[arg-type]
142
+ shape=PartModel.Tile10x10,
143
+ speed=float(get_values(elem, "anmspd")[0]),
144
+ )
145
+ )
146
+ case DeviceModel.MovingTile20x20:
147
+ output.add(
148
+ MovingTile(
149
+ *get_pos_rot(elem),
150
+ **dict(
151
+ zip(
152
+ ("dest_x", "dest_y", "dest_z"),
153
+ map(float, get_values(elem, "anmmov1")),
154
+ )
155
+ ), # type: ignore[arg-type]
156
+ shape=PartModel.Tile20x20,
157
+ speed=float(get_values(elem, "anmspd")[0]),
158
+ walls=(
159
+ Walls(0)
160
+ if elem.find("hook") is None
161
+ else Walls(int(get_values(elem, "hook")[1]))
162
+ ),
163
+ )
164
+ )
165
+ case DeviceModel.MovingTile30x30:
166
+ output.add(
167
+ MovingTile(
168
+ *get_pos_rot(elem),
169
+ **dict(
170
+ zip(
171
+ ("dest_x", "dest_y", "dest_z"),
172
+ map(float, get_values(elem, "anmmov1")),
173
+ )
174
+ ), # type: ignore[arg-type]
175
+ shape=PartModel.TileA30x30,
176
+ speed=float(get_values(elem, "anmspd")[0]),
177
+ walls=(
178
+ Walls(0)
179
+ if elem.find("hook") is None
180
+ else Walls(int(get_values(elem, "hook")[1]))
181
+ ),
182
+ )
183
+ )
184
+ case DeviceModel.MovingTile30x90:
185
+ output.add(
186
+ MovingTile(
187
+ *get_pos_rot(elem),
188
+ **dict(
189
+ zip(
190
+ ("dest_x", "dest_y", "dest_z"),
191
+ map(float, get_values(elem, "anmmov1")),
192
+ )
193
+ ), # type: ignore[arg-type]
194
+ shape=PartModel.TileA30x90,
195
+ speed=float(get_values(elem, "anmspd")[0]),
196
+ )
197
+ )
198
+ case DeviceModel.MovingTile90x90A:
199
+ output.add(
200
+ MovingTile(
201
+ *get_pos_rot(elem),
202
+ **dict(
203
+ zip(
204
+ ("dest_x", "dest_y", "dest_z"),
205
+ map(float, get_values(elem, "anmmov1")),
206
+ )
207
+ ), # type: ignore[arg-type]
208
+ shape=PartModel.Tile90x90,
209
+ speed=float(get_values(elem, "anmspd")[0]),
210
+ )
211
+ )
212
+ case DeviceModel.MovingTile90x90B:
213
+ output.add(
214
+ MovingTile(
215
+ *get_pos_rot(elem),
216
+ **dict(
217
+ zip(
218
+ ("dest_x", "dest_y", "dest_z"),
219
+ map(float, get_values(elem, "anmmov1")),
220
+ )
221
+ ), # type: ignore[arg-type]
222
+ shape=PartModel.HoleB90x90,
223
+ speed=float(get_values(elem, "anmspd")[0]),
224
+ )
225
+ )
226
+ case DeviceModel.MovingTile10x10Switch:
227
+ output.add(
228
+ MovingTile(
229
+ *get_pos_rot(elem),
230
+ **dict(
231
+ zip(
232
+ ("dest_x", "dest_y", "dest_z"),
233
+ map(float, get_values(elem, "anmmov1")),
234
+ )
235
+ ), # type: ignore[arg-type]
236
+ shape=PartModel.Tile10x10,
237
+ speed=float(get_values(elem, "anmspd")[0]),
238
+ switch=True,
239
+ )
240
+ )
241
+ case DeviceModel.MovingTile20x20Switch:
242
+ output.add(
243
+ MovingTile(
244
+ *get_pos_rot(elem),
245
+ **dict(
246
+ zip(
247
+ ("dest_x", "dest_y", "dest_z"),
248
+ map(float, get_values(elem, "anmmov1")),
249
+ )
250
+ ), # type: ignore[arg-type]
251
+ shape=PartModel.Tile20x20,
252
+ speed=float(get_values(elem, "anmspd")[0]),
253
+ switch=True,
254
+ walls=(
255
+ Walls(0)
256
+ if elem.find("hook") is None
257
+ else Walls(int(get_values(elem, "hook")[1]))
258
+ ),
259
+ )
260
+ )
261
+ case DeviceModel.MovingTile30x30Switch:
262
+ output.add(
263
+ MovingTile(
264
+ *get_pos_rot(elem),
265
+ **dict(
266
+ zip(
267
+ ("dest_x", "dest_y", "dest_z"),
268
+ map(float, get_values(elem, "anmmov1")),
269
+ )
270
+ ), # type: ignore[arg-type]
271
+ shape=PartModel.TileA30x30,
272
+ speed=float(get_values(elem, "anmspd")[0]),
273
+ switch=True,
274
+ walls=(
275
+ Walls(0)
276
+ if elem.find("hook") is None
277
+ else Walls(int(get_values(elem, "hook")[1]))
278
+ ),
279
+ )
280
+ )
281
+ case DeviceModel.MovingTile30x90Switch:
282
+ output.add(
283
+ MovingTile(
284
+ *get_pos_rot(elem),
285
+ **dict(
286
+ zip(
287
+ ("dest_x", "dest_y", "dest_z"),
288
+ map(float, get_values(elem, "anmmov1")),
289
+ )
290
+ ), # type: ignore[arg-type]
291
+ shape=PartModel.TileA30x90,
292
+ speed=float(get_values(elem, "anmspd")[0]),
293
+ switch=True,
294
+ )
295
+ )
296
+ case DeviceModel.MovingTile90x90ASwitch:
297
+ output.add(
298
+ MovingTile(
299
+ *get_pos_rot(elem),
300
+ **dict(
301
+ zip(
302
+ ("dest_x", "dest_y", "dest_z"),
303
+ map(float, get_values(elem, "anmmov1")),
304
+ )
305
+ ), # type: ignore[arg-type]
306
+ shape=PartModel.Tile90x90,
307
+ speed=float(get_values(elem, "anmspd")[0]),
308
+ switch=True,
309
+ )
310
+ )
311
+ case DeviceModel.MovingTile90x90BSwitch:
312
+ output.add(
313
+ MovingTile(
314
+ *get_pos_rot(elem),
315
+ **dict(
316
+ zip(
317
+ ("dest_x", "dest_y", "dest_z"),
318
+ map(float, get_values(elem, "anmmov1")),
319
+ )
320
+ ), # type: ignore[arg-type]
321
+ shape=PartModel.HoleB90x90,
322
+ speed=float(get_values(elem, "anmspd")[0]),
323
+ switch=True,
324
+ )
325
+ )
326
+ case DeviceModel.MovingFunnelPipe:
327
+ output.add(
328
+ MovingTile(
329
+ *get_pos_rot(elem),
330
+ **dict(
331
+ zip(
332
+ ("dest_x", "dest_y", "dest_z"),
333
+ map(float, get_values(elem, "anmmov1")),
334
+ )
335
+ ), # type: ignore[arg-type]
336
+ shape=PartModel.FunnelPipe,
337
+ speed=float(get_values(elem, "anmspd")[0]),
338
+ )
339
+ )
340
+ case DeviceModel.MovingStraightPipe:
341
+ output.add(
342
+ MovingTile(
343
+ *get_pos_rot(elem),
344
+ **dict(
345
+ zip(
346
+ ("dest_x", "dest_y", "dest_z"),
347
+ map(float, get_values(elem, "anmmov1")),
348
+ )
349
+ ), # type: ignore[arg-type]
350
+ shape=PartModel.StraightPipe,
351
+ speed=float(get_values(elem, "anmspd")[0]),
352
+ )
353
+ )
354
+ case DeviceModel.MovingCurveS:
355
+ output.add(
356
+ MovingCurve(
357
+ *get_pos_rot(elem),
358
+ shape=PartModel.CurveS,
359
+ speed=Speed(int(get_values(elem, "sts")[0])),
360
+ )
361
+ )
362
+ case DeviceModel.MovingCurveM:
363
+ output.add(
364
+ MovingCurve(
365
+ *get_pos_rot(elem),
366
+ shape=PartModel.CurveM,
367
+ speed=Speed(int(get_values(elem, "sts")[0])),
368
+ )
369
+ )
370
+ case DeviceModel.MovingCurveL:
371
+ output.add(
372
+ MovingCurve(
373
+ *get_pos_rot(elem),
374
+ shape=PartModel.CurveL,
375
+ speed=Speed(int(get_values(elem, "sts")[0])),
376
+ )
377
+ )
378
+ case DeviceModel.SlidingTile:
379
+ output.add(SlidingTile(*get_pos_rot(elem)))
380
+ case DeviceModel.ConveyorBelt:
381
+ output.add(
382
+ ConveyorBelt(
383
+ *get_pos_rot(elem),
384
+ reversing=get_values(elem, "sts")[0] == "39",
385
+ )
386
+ )
387
+ case DeviceModel.DashTunnelA:
388
+ output.add(
389
+ DashTunnel(
390
+ *get_pos_rot(elem),
391
+ shape=DeviceModel.DashTunnelA,
392
+ )
393
+ )
394
+ case DeviceModel.DashTunnelB:
395
+ output.add(
396
+ DashTunnel(
397
+ *get_pos_rot(elem),
398
+ shape=DeviceModel.DashTunnelB,
399
+ )
400
+ )
401
+ case DeviceModel.SeesawLBlock:
402
+ output.add(
403
+ SeesawBlock(
404
+ *get_pos_rot(elem),
405
+ shape=DeviceModel.SeesawLBlock,
406
+ )
407
+ )
408
+ case DeviceModel.SeesawIBlock:
409
+ output.add(
410
+ SeesawBlock(
411
+ *get_pos_rot(elem),
412
+ shape=DeviceModel.SeesawIBlock,
413
+ )
414
+ )
415
+ case DeviceModel.AutoSeesawLBlock:
416
+ output.add(
417
+ SeesawBlock(
418
+ *get_pos_rot(elem),
419
+ auto=True,
420
+ shape=DeviceModel.SeesawLBlock,
421
+ )
422
+ )
423
+ case DeviceModel.AutoSeesawIBlock:
424
+ output.add(
425
+ SeesawBlock(
426
+ *get_pos_rot(elem),
427
+ auto=True,
428
+ shape=DeviceModel.SeesawIBlock,
429
+ )
430
+ )
431
+ case DeviceModel.Cannon:
432
+ output.add(Cannon(*get_pos_rot(elem)))
433
+ case DeviceModel.Drawbridge:
434
+ output.add(Drawbridge(*get_pos_rot(elem)))
435
+ case DeviceModel.Turntable:
436
+ output.add(
437
+ Turntable(
438
+ *get_pos_rot(elem),
439
+ speed=Speed(int(get_values(elem, "sts")[0])),
440
+ )
441
+ )
442
+ case DeviceModel.Bumper:
443
+ output.add(Bumper(*get_pos_rot(elem)))
444
+ case DeviceModel.PowerfulBumper:
445
+ output.add(Bumper(*get_pos_rot(elem), powerful=True))
446
+ case DeviceModel.Thorn:
447
+ output.add(Thorn(*get_pos_rot(elem)))
448
+ case DeviceModel.Gear:
449
+ output.add(
450
+ Gear(
451
+ *get_pos_rot(elem),
452
+ speed=Speed(int(get_values(elem, "sts")[0])),
453
+ )
454
+ )
455
+ case DeviceModel.Fan:
456
+ output.add(Fan(*get_pos_rot(elem)))
457
+ case DeviceModel.PowerfulFan:
458
+ output.add(
459
+ Fan(
460
+ *get_pos_rot(elem),
461
+ wind_pattern=DeviceModel.PowerfulFan,
462
+ )
463
+ )
464
+ case DeviceModel.TimerFan:
465
+ output.add(
466
+ Fan(
467
+ *get_pos_rot(elem),
468
+ wind_pattern=DeviceModel.TimerFan,
469
+ )
470
+ )
471
+ case DeviceModel.Spring:
472
+ output.add(Spring(*get_pos_rot(elem)))
473
+ case DeviceModel.Punch:
474
+ output.add(
475
+ Punch(
476
+ *get_pos_rot(elem),
477
+ timing=MovementTiming(
478
+ int(get_values(elem, "sts")[0])
479
+ ),
480
+ )
481
+ )
482
+ case DeviceModel.Press:
483
+ output.add(
484
+ Press(
485
+ *get_pos_rot(elem),
486
+ timing=MovementTiming(
487
+ int(get_values(elem, "sts")[0])
488
+ ),
489
+ )
490
+ )
491
+ case DeviceModel.Scissors:
492
+ output.add(
493
+ Scissors(
494
+ *get_pos_rot(elem),
495
+ timing=MovementTiming(
496
+ int(get_values(elem, "sts")[0])
497
+ ),
498
+ )
499
+ )
500
+ case DeviceModel.MagnifyingGlass:
501
+ output.add(MagnifyingGlass(*get_pos_rot(elem)))
502
+ case DeviceModel.UpsideDownStageDevice:
503
+ output.add(UpsideDownStageDevice(*get_pos_rot(elem)))
504
+ case DeviceModel.UpsideDownBall:
505
+ output.add(UpsideDownBall(*get_pos_rot(elem)))
506
+ case DeviceModel.SmallTunnel:
507
+ output.add(
508
+ SizeTunnel(
509
+ *get_pos_rot(elem), size=DeviceModel.SmallTunnel
510
+ )
511
+ )
512
+ case DeviceModel.BigTunnel:
513
+ output.add(
514
+ SizeTunnel(
515
+ *get_pos_rot(elem), size=DeviceModel.BigTunnel
516
+ )
517
+ )
518
+ case DeviceModel.BlinkingTile:
519
+ output.add(
520
+ BlinkingTile(
521
+ *get_pos_rot(elem),
522
+ timing=MovementTiming(
523
+ int(get_values(elem, "sts")[0])
524
+ ),
525
+ )
526
+ )
527
+ case DeviceModel.CubicTextBox:
528
+ output.add(
529
+ TextBox(
530
+ *get_pos_rot(elem),
531
+ shape=DeviceModel.CubicTextBox,
532
+ text_id=int(get_values(elem, "sts")[0]),
533
+ )
534
+ )
535
+ case DeviceModel.WallTextBox:
536
+ output.add(
537
+ TextBox(
538
+ *get_pos_rot(elem),
539
+ shape=DeviceModel.WallTextBox,
540
+ text_id=int(get_values(elem, "sts")[0]),
541
+ )
542
+ )
543
+ case DeviceModel.KororinCapsule:
544
+ output.add(KororinCapsule(*get_pos_rot(elem)))
545
+ case DeviceModel.GreenCrystal:
546
+ output.add(GreenCrystal(*get_pos_rot(elem)))
547
+ case DeviceModel.Ant:
548
+ output.add(Ant(*get_pos_rot(elem)))
549
+ case model if model.name.startswith("MelodyTile"):
550
+ output.add(MelodyTile(*get_pos_rot(elem), note=model)) # type: ignore[arg-type]
551
+ case _:
552
+ groups.setdefault(int(get_values(elem, "group")[0]), {})[
553
+ int(get_values(elem, "group")[1])
554
+ ] = elem
555
+ for group in groups.values():
556
+ match DeviceModel(int(get_values(group[0], "model")[1])):
557
+ case DeviceModel.EndMagnet:
558
+ m: Magnet = Magnet()
559
+ for _, elem in sorted(group.items()):
560
+ m.append(
561
+ MagnetSegment(
562
+ *get_pos_rot(elem),
563
+ shape=DeviceModel(int(get_values(elem, "model")[1])), # type: ignore[arg-type]
564
+ )
565
+ )
566
+ output.add(m)
567
+ case DeviceModel.ToyTrain:
568
+ t: ToyTrain = ToyTrain(*get_pos_rot(group[0]))
569
+ for i, elem in sorted(group.items()):
570
+ if i:
571
+ t.append(
572
+ TrainTrack(
573
+ *get_pos_rot(elem),
574
+ shape=DeviceModel(int(get_values(elem, "model")[1])), # type: ignore[arg-type]
575
+ )
576
+ )
577
+ output.add(t)
578
+ case DeviceModel.Warp:
579
+ output.add(
580
+ Warp(
581
+ *get_pos_rot(group[0]),
582
+ **dict(
583
+ zip(
584
+ ("dest_x", "dest_y", "dest_z"),
585
+ map(float, get_values(group[0], "anmmov0")),
586
+ )
587
+ ),
588
+ **dict(
589
+ zip(
590
+ (
591
+ "return_x_pos",
592
+ "return_y_pos",
593
+ "return_z_pos",
594
+ "return_x_rot",
595
+ "return_y_rot",
596
+ "return_z_rot",
597
+ ),
598
+ get_pos_rot(group[1]),
599
+ strict=True,
600
+ )
601
+ ),
602
+ **dict(
603
+ zip(
604
+ ("return_dest_x", "return_dest_y", "return_dest_z"),
605
+ map(float, get_values(group[1], "anmmov0")),
606
+ )
607
+ ),
608
+ ) # type: ignore[misc]
609
+ )
610
+ return output
611
+
612
+ @staticmethod
613
+ def serialize(stage: Stage, /) -> bytes:
614
+ def minify(value: float, /) -> str:
615
+ """Removes the decimal point from floats representing integers."""
616
+ return str(int(value) if value.is_integer() else value)
617
+
618
+ def serialize_numbers(*values: float) -> str:
619
+ """Does not include leading or trailing spaces."""
620
+ return " ".join(minify(value) for value in values)
621
+
622
+ def anmtype(device: BasePart, /) -> DeviceModel:
623
+ if isinstance(device, ProgressMarker):
624
+ return (
625
+ DeviceModel.Crystal if device.progress % 2 else DeviceModel.Respawn
626
+ )
627
+ elif isinstance(device, MovingTile):
628
+ match device.shape:
629
+ case PartModel.Tile10x10:
630
+ return (
631
+ DeviceModel.MovingTile10x10Switch
632
+ if device.switch
633
+ else DeviceModel.MovingTile10x10
634
+ )
635
+ case PartModel.Tile20x20:
636
+ return (
637
+ DeviceModel.MovingTile20x20Switch
638
+ if device.switch
639
+ else DeviceModel.MovingTile20x20
640
+ )
641
+ case PartModel.TileA30x30:
642
+ return (
643
+ DeviceModel.MovingTile30x30Switch
644
+ if device.switch
645
+ else DeviceModel.MovingTile30x30
646
+ )
647
+ case PartModel.TileA30x90:
648
+ return (
649
+ DeviceModel.MovingTile30x90Switch
650
+ if device.switch
651
+ else DeviceModel.MovingTile30x90
652
+ )
653
+ case PartModel.Tile90x90:
654
+ return (
655
+ DeviceModel.MovingTile90x90ASwitch
656
+ if device.switch
657
+ else DeviceModel.MovingTile90x90A
658
+ )
659
+ case PartModel.HoleB90x90:
660
+ return (
661
+ DeviceModel.MovingTile90x90BSwitch
662
+ if device.switch
663
+ else DeviceModel.MovingTile90x90B
664
+ )
665
+ case PartModel.FunnelPipe:
666
+ return DeviceModel.MovingFunnelPipe
667
+ case PartModel.StraightPipe:
668
+ return DeviceModel.MovingStraightPipe
669
+ elif isinstance(device, MovingCurve):
670
+ match device.shape:
671
+ case PartModel.CurveS:
672
+ return DeviceModel.MovingCurveS
673
+ case PartModel.CurveM:
674
+ return DeviceModel.MovingCurveM
675
+ case PartModel.CurveL:
676
+ return DeviceModel.MovingCurveL
677
+ elif isinstance(device, SlidingTile):
678
+ return DeviceModel.SlidingTile
679
+ elif isinstance(device, ConveyorBelt):
680
+ return DeviceModel.ConveyorBelt
681
+ elif isinstance(device, (DashTunnel, TextBox)):
682
+ return device.shape
683
+ elif isinstance(device, SeesawBlock):
684
+ if device.auto:
685
+ match device.shape:
686
+ case DeviceModel.SeesawLBlock:
687
+ return DeviceModel.AutoSeesawLBlock
688
+ case DeviceModel.SeesawIBlock:
689
+ return DeviceModel.AutoSeesawIBlock
690
+ else:
691
+ return device.shape
692
+ elif isinstance(device, Cannon):
693
+ return DeviceModel.Cannon
694
+ elif isinstance(device, Drawbridge):
695
+ return DeviceModel.Drawbridge
696
+ elif isinstance(device, Turntable):
697
+ return DeviceModel.Turntable
698
+ elif isinstance(device, Bumper):
699
+ return (
700
+ DeviceModel.PowerfulBumper
701
+ if device.powerful
702
+ else DeviceModel.Bumper
703
+ )
704
+ elif isinstance(device, Thorn):
705
+ return DeviceModel.Thorn
706
+ elif isinstance(device, Gear):
707
+ return DeviceModel.Gear
708
+ elif isinstance(device, Fan):
709
+ return device.wind_pattern
710
+ elif isinstance(device, Spring):
711
+ return DeviceModel.Spring
712
+ elif isinstance(device, Punch):
713
+ return DeviceModel.Punch
714
+ elif isinstance(device, Press):
715
+ return DeviceModel.Press
716
+ elif isinstance(device, Scissors):
717
+ return DeviceModel.Scissors
718
+ elif isinstance(device, MagnifyingGlass):
719
+ return DeviceModel.MagnifyingGlass
720
+ elif isinstance(device, UpsideDownStageDevice):
721
+ return DeviceModel.UpsideDownStageDevice
722
+ elif isinstance(device, UpsideDownBall):
723
+ return DeviceModel.UpsideDownBall
724
+ elif isinstance(device, SizeTunnel):
725
+ return device.size
726
+ elif isinstance(device, BlinkingTile):
727
+ return DeviceModel.BlinkingTile
728
+ elif isinstance(device, MelodyTile):
729
+ return device.note
730
+ elif isinstance(device, KororinCapsule):
731
+ return DeviceModel.KororinCapsule
732
+ elif isinstance(device, GreenCrystal):
733
+ return DeviceModel.GreenCrystal
734
+ elif isinstance(device, Ant):
735
+ return DeviceModel.Ant
736
+ else:
737
+ raise ValueError(f"part {device!r} does not have a known anmtype")
738
+
739
+ def device_data(device: BasePart, /) -> str:
740
+ if isinstance(device, ProgressMarker):
741
+ return f"<hook> {(device._progress - 1) // 2} 0 </hook>\n"
742
+ elif isinstance(device, MovingTile):
743
+ anmmov: Final[str] = (
744
+ f"<anmspd> {minify(device.speed)} 0 </anmspd>\n<anmmov0> {serialize_numbers(device.x_pos, device.y_pos, device.z_pos)} </anmmov0>\n<anmmov1> {serialize_numbers(device.dest_x, device.dest_y, device.dest_z)} </anmmov1>"
745
+ )
746
+ if device.walls:
747
+ match device.shape:
748
+ case PartModel.Tile20x20:
749
+ return f"<hook> {DeviceModel.MovingTile20x20Wall.value} {device.walls.value} </hook>\n{anmmov}\n"
750
+ case PartModel.TileA30x30:
751
+ return f"<hook> {DeviceModel.MovingTile30x30Wall.value} {device.walls.value} </hook>\n{anmmov}\n"
752
+ return anmmov
753
+ else:
754
+ return ""
755
+
756
+ def sts(part: BasePart, /) -> int:
757
+ if isinstance(part, FixedSpeedDevice):
758
+ return part.speed.value
759
+ elif isinstance(part, ConveyorBelt):
760
+ return 39 if part.reversing else 23
761
+ elif isinstance(part, TimedDevice):
762
+ return part.timing.value
763
+ else:
764
+ return 7
765
+
766
+ with StringIO(
767
+ f'<?xml version="1.0" encoding="SHIFT_JIS"?>\n<EDITINFO>\n<THEME> {stage.theme.value} </THEME>\n<LOCK> {int(stage.tilt_lock)} </LOCK>\n<EDITUSER> {stage.edit_user.value} </EDITUSER>\n</EDITINFO>\n<STAGEDATA>\n<EDIT_BG_NORMAL>\n<model> "EBB_{stage.theme.value:02}.bin 0 </model>\n</EDIT_BG_NORMAL>\n'
768
+ ) as output:
769
+ output.seek(0, SEEK_END)
770
+ group: int = 1
771
+ for part in stage:
772
+ if isinstance(part, Magnet):
773
+ for i, segment in enumerate(part):
774
+ output.write(
775
+ f'<EDIT_GIM_NORMAL>\n<model> "EGB_{stage.theme.value:02}.bin" {segment.shape.value} </model>\n<pos> {serialize_numbers(segment.x_pos, segment.y_pos, segment.z_pos)} </pos>\n<rot> {serialize_numbers(segment.x_rot, segment.y_rot, segment.z_rot)} </rot>\n<sts> 7 </sts>\n<group> {group} {i} </group>\n</EDIT_GIM_NORMAL>\n'
776
+ )
777
+ group += 1
778
+ elif isinstance(part, ToyTrain):
779
+ output.write(
780
+ f'<EDIT_GIM_NORMAL>\n<model> "EGB_{stage.theme.value:02}.bin" {DeviceModel.ToyTrain.value} </model>\n<pos> {serialize_numbers(part.x_pos, part.y_pos, part.z_pos)} </pos>\n<rot> {serialize_numbers(part.x_rot, part.y_rot, part.z_rot)} </rot>\n<sts> 7 </sts>\n<group> {group} 0 </group>\n</EDIT_GIM_NORMAL>\n'
781
+ )
782
+ for i, track in enumerate(part, 1):
783
+ output.write(
784
+ f'<EDIT_GIM_NORMAL>\n<model> "EGB_{stage.theme.value:02}.bin" {track.shape.value} </model>\n<pos> {serialize_numbers(track.x_pos, track.y_pos, track.z_pos)} </pos>\n<rot> {serialize_numbers(track.x_rot, track.y_rot, track.z_rot)} </rot>\n<sts> 7 </sts>\n<group> {group} {i} </group>\n</EDIT_GIM_NORMAL>\n'
785
+ )
786
+ group += 1
787
+ elif isinstance(part, Warp):
788
+ output.write(
789
+ f'<EDIT_GIM_NORMAL>\n<model> "EGB_{stage.theme.value:02}.bin" {DeviceModel.Warp.value} </model>\n<pos> {serialize_numbers(part.x_pos, part.y_pos, part.z_pos)} </pos>\n<rot> {serialize_numbers(part.x_rot, part.y_rot, part.z_rot)} </rot>\n<sts> 7 </sts>\n<anmmov0> {serialize_numbers(part.dest_x, part.dest_y, part.dest_z)} </anmmov0>\n<group> {group} 0 </group>\n</EDIT_GIM_NORMAL>\n<EDIT_GIM_NORMAL>\n<model> "EGB_{stage.theme.value:02}.bin" {DeviceModel.Warp.value} </model>\n<pos> {serialize_numbers(part.return_x_pos, part.return_y_pos, part.return_z_pos)} </pos>\n<rot> {serialize_numbers(part.return_x_rot, part.return_y_rot, part.return_z_rot)} </rot>\n<sts> 7 </sts>\n<anmmov0> {serialize_numbers(part.return_dest_x, part.return_dest_y, part.return_dest_z)} </anmmov0>\n<group> {group} 1 </group>\n</EDIT_GIM_NORMAL>\n'
790
+ )
791
+ group += 1
792
+ else:
793
+ if isinstance(part, Start):
794
+ output.write("<EDIT_GIM_START>\n")
795
+ elif isinstance(part, Goal):
796
+ output.write("<EDIT_GIM_GOAL>\n")
797
+ elif isinstance(part, Part):
798
+ if isinstance(part.shape, DecorationModel):
799
+ output.write("<EDIT_MAP_EXT>\n")
800
+ else:
801
+ output.write("<EDIT_MAP_NORMAL>\n")
802
+ else:
803
+ output.write("<EDIT_GIM_NORMAL>\n")
804
+ if isinstance(part, Part):
805
+ if isinstance(part.shape, DecorationModel):
806
+ output.write(
807
+ f'<model> "EME_{stage.theme.value:02}.bin" {part.shape.value} </model>\n'
808
+ )
809
+ else:
810
+ output.write(
811
+ f'<model> "EMB_{stage.theme.value:02}.bin" {part.shape.value} </model>\n'
812
+ )
813
+ elif isinstance(part, Start):
814
+ output.write(
815
+ f'<model> "EGB_{stage.theme.value:02}.bin" 0 </model>\n'
816
+ )
817
+ elif isinstance(part, Goal):
818
+ output.write(
819
+ f'<model> "EGB_{stage.theme.value:02}.bin" 1 </model>\n'
820
+ )
821
+ else:
822
+ output.write(
823
+ f'<model> "EGB_{stage.theme.value:02}.bin" {anmtype(part).value} </model>\n'
824
+ )
825
+ output.write(
826
+ f"<pos> {serialize_numbers(part.x_pos, part.y_pos, part.z_pos)} </pos>\n<rot> {serialize_numbers(part.x_rot, part.y_rot, part.z_rot)} </rot>\n<sts> {sts(part)} </sts>\n"
827
+ )
828
+ try:
829
+ output.write(f"<anmtype> {anmtype(part).value} </anmtype>\n")
830
+ except ValueError:
831
+ pass
832
+ output.write(device_data(part))
833
+ if isinstance(part, Start):
834
+ output.write("</EDIT_GIM_START>\n")
835
+ elif isinstance(part, Goal):
836
+ output.write("</EDIT_GIM_GOAL>\n")
837
+ elif isinstance(part, Part):
838
+ if isinstance(part.shape, DecorationModel):
839
+ output.write("</EDIT_MAP_EXT>\n")
840
+ else:
841
+ output.write("</EDIT_MAP_NORMAL>\n")
842
+ else:
843
+ output.write("</EDIT_GIM_NORMAL>\n")
844
+ output.write("</STAGEDATA>")
845
+ return output.getvalue().encode("shift_jis", "xmlcharrefreplace")