mini-arcade-core 1.2.0__py3-none-any.whl → 1.2.2__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.
@@ -5,9 +5,11 @@ Command protocol for executing commands with a given context.
5
5
  from __future__ import annotations
6
6
 
7
7
  from dataclasses import dataclass, field
8
+ from pathlib import Path
8
9
  from typing import TYPE_CHECKING, List, Optional, Protocol, TypeVar
9
10
 
10
11
  from mini_arcade_core.engine.scenes.models import ScenePolicy
12
+ from mini_arcade_core.runtime.capture.replay_format import ReplayHeader
11
13
 
12
14
  if TYPE_CHECKING:
13
15
  from mini_arcade_core.runtime.services import RuntimeServices
@@ -121,7 +123,7 @@ class ScreenshotCommand(Command):
121
123
  self,
122
124
  context: CommandContext,
123
125
  ):
124
- context.services.capture.screenshot(label=self.label, mode="manual")
126
+ context.services.capture.screenshot(label=self.label)
125
127
 
126
128
 
127
129
  @dataclass(frozen=True)
@@ -216,3 +218,69 @@ class ToggleEffectCommand(Command):
216
218
  if stack is None:
217
219
  return
218
220
  stack.toggle(self.effect_id)
221
+
222
+
223
+ @dataclass(frozen=True)
224
+ class StartReplayRecordCommand(Command):
225
+ """
226
+ Start recording a replay to the specified file.
227
+
228
+ :ivar filename (str): The filename to save the replay to.
229
+ :ivar game_id (str): Identifier of the game.
230
+ :ivar initial_scene (str): The initial scene of the replay.
231
+ :ivar seed (int): The random seed used in the replay.
232
+ :ivar fps (int): Frames per second for the replay.
233
+ """
234
+
235
+ filename: str
236
+ game_id: str = "mini-arcade"
237
+ initial_scene: str = "unknown"
238
+ seed: int = 0
239
+ fps: int = 60
240
+
241
+ def execute(self, context: CommandContext):
242
+ header = ReplayHeader(
243
+ game_id=self.game_id,
244
+ initial_scene=self.initial_scene,
245
+ seed=self.seed,
246
+ fps=self.fps,
247
+ )
248
+ context.services.capture.start_replay_record(
249
+ filename=self.filename,
250
+ header=header,
251
+ )
252
+
253
+
254
+ @dataclass(frozen=True)
255
+ class StopReplayRecordCommand(Command):
256
+ """Stop recording the current replay."""
257
+
258
+ def execute(self, context: CommandContext):
259
+ context.services.capture.stop_replay_record()
260
+
261
+
262
+ @dataclass(frozen=True)
263
+ class StartReplayPlayCommand(Command):
264
+ """
265
+ Start playing back a replay from the specified file.
266
+
267
+ :ivar path (str): The path to the replay file.
268
+ :ivar change_scene (bool): Whether to change to the replay's initial scene.
269
+ """
270
+
271
+ path: str
272
+ change_scene: bool = True
273
+
274
+ def execute(self, context: CommandContext):
275
+ header = context.services.capture.start_replay_play(Path(self.path))
276
+ if self.change_scene:
277
+ # NOTE: **IMPORTANT** align game state with the replay header
278
+ context.managers.scenes.change(header.initial_scene)
279
+
280
+
281
+ @dataclass(frozen=True)
282
+ class StopReplayPlayCommand(Command):
283
+ """Stop playing back the current replay."""
284
+
285
+ def execute(self, context: CommandContext):
286
+ context.services.capture.stop_replay_play()
@@ -24,7 +24,7 @@ from mini_arcade_core.engine.render.pipeline import RenderPipeline
24
24
  from mini_arcade_core.engine.render.render_service import RenderService
25
25
  from mini_arcade_core.engine.scenes.scene_manager import SceneAdapter
26
26
  from mini_arcade_core.runtime.audio.audio_adapter import SDLAudioAdapter
27
- from mini_arcade_core.runtime.capture.capture_adapter import CaptureAdapter
27
+ from mini_arcade_core.runtime.capture.capture_service import CaptureService
28
28
  from mini_arcade_core.runtime.file.file_adapter import LocalFilesAdapter
29
29
  from mini_arcade_core.runtime.input.input_adapter import InputAdapter
30
30
  from mini_arcade_core.runtime.scene.scene_query_adapter import (
@@ -70,10 +70,12 @@ class Game:
70
70
  ),
71
71
  )
72
72
  self.services = RuntimeServices(
73
- window=WindowAdapter(self.backend), # Turn into a manager?
73
+ window=WindowAdapter(self.backend),
74
74
  audio=SDLAudioAdapter(self.backend),
75
75
  files=LocalFilesAdapter(),
76
- capture=CaptureAdapter(self.backend),
76
+ capture=CaptureService(
77
+ self.backend
78
+ ), # NOTE: Should actually be a manager?
77
79
  input=InputAdapter(),
78
80
  render=RenderService(),
79
81
  scenes=SceneQueryAdapter(self.managers.scenes),
@@ -139,14 +139,37 @@ class EngineRunner:
139
139
  def _build_input(
140
140
  self, events, *, frame: FrameState, timer: FrameTimer | None
141
141
  ):
142
- # Build InputFrame from events.
143
- input_frame = self.services.input.build(
144
- events, frame.frame_index, frame.dt
145
- )
142
+ cap = self.services.capture
143
+
144
+ if cap.replay_playing:
145
+ input_frame = cap.next_replay_input()
146
+
147
+ # optional but recommended: keep runner's frame_index/dt authoritative
148
+ input_frame = InputFrame(
149
+ frame_index=frame.frame_index,
150
+ dt=frame.dt,
151
+ keys_down=input_frame.keys_down,
152
+ keys_pressed=input_frame.keys_pressed,
153
+ keys_released=input_frame.keys_released,
154
+ buttons=input_frame.buttons,
155
+ axes=input_frame.axes,
156
+ mouse_pos=input_frame.mouse_pos,
157
+ mouse_delta=input_frame.mouse_delta,
158
+ text_input=input_frame.text_input,
159
+ quit=input_frame.quit,
160
+ )
161
+ else:
162
+ input_frame = self.services.input.build(
163
+ events, frame.frame_index, frame.dt
164
+ )
165
+
146
166
  if timer:
147
167
  timer.mark("input_built")
168
+
148
169
  if input_frame.quit:
149
170
  self.managers.command_queue.push(QuitCommand())
171
+
172
+ cap.record_input(input_frame)
150
173
  return input_frame
151
174
 
152
175
  def _should_quit(self, input_frame: InputFrame) -> bool:
@@ -0,0 +1,96 @@
1
+ """
2
+ Capture service managing screenshots and replays.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from mini_arcade_core.backend import Backend
11
+ from mini_arcade_core.runtime.capture.capture_service_protocol import (
12
+ CaptureServicePort,
13
+ )
14
+ from mini_arcade_core.runtime.capture.capture_settings import CaptureSettings
15
+ from mini_arcade_core.runtime.capture.replay import (
16
+ ReplayPlayer,
17
+ ReplayRecorder,
18
+ ReplayRecorderConfig,
19
+ )
20
+ from mini_arcade_core.runtime.capture.replay_format import ReplayHeader
21
+ from mini_arcade_core.runtime.capture.screenshot_capturer import (
22
+ ScreenshotCapturer,
23
+ )
24
+ from mini_arcade_core.runtime.input_frame import InputFrame
25
+
26
+
27
+ class CaptureService(CaptureServicePort):
28
+ """
29
+ Owns:
30
+ - screenshots (delegated)
31
+ - replay recording (InputFrame stream)
32
+ - replay playback (feeds InputFrames)
33
+ - (later) video recording
34
+ """
35
+
36
+ # pylint: disable=too-many-arguments
37
+ def __init__(
38
+ self,
39
+ backend: Backend,
40
+ *,
41
+ screenshots: Optional[ScreenshotCapturer] = None,
42
+ replay_recorder: Optional[ReplayRecorder] = None,
43
+ replay_player: Optional[ReplayPlayer] = None,
44
+ settings: Optional[CaptureSettings] = None,
45
+ ):
46
+ self.backend = backend
47
+ self.settings = settings or CaptureSettings()
48
+
49
+ self.screenshots = screenshots or ScreenshotCapturer(backend)
50
+ self.replay_recorder = replay_recorder or ReplayRecorder()
51
+ self.replay_player = replay_player or ReplayPlayer()
52
+
53
+ # -------- screenshots --------
54
+ def screenshot(self, label: str | None = None) -> str:
55
+ return self.screenshots.screenshot(label)
56
+
57
+ def screenshot_sim(
58
+ self, run_id: str, frame_index: int, label: str = "frame"
59
+ ) -> str:
60
+ return self.screenshots.screenshot_sim(run_id, frame_index, label)
61
+
62
+ # -------- replays --------
63
+ @property
64
+ def replay_playing(self) -> bool:
65
+ return self.replay_player.active
66
+
67
+ @property
68
+ def replay_recording(self) -> bool:
69
+ return self.replay_recorder.active
70
+
71
+ def start_replay_record(
72
+ self,
73
+ *,
74
+ filename: str,
75
+ header: ReplayHeader,
76
+ ) -> None:
77
+ path = Path(self.settings.replays_dir) / filename
78
+ self.replay_recorder.start(
79
+ ReplayRecorderConfig(path=path, header=header)
80
+ )
81
+
82
+ def stop_replay_record(self) -> None:
83
+ self.replay_recorder.stop()
84
+
85
+ def record_input(self, frame: InputFrame) -> None:
86
+ self.replay_recorder.record(frame)
87
+
88
+ def start_replay_play(self, filename: str) -> ReplayHeader:
89
+ path = Path(self.settings.replays_dir) / filename
90
+ return self.replay_player.start(path)
91
+
92
+ def stop_replay_play(self) -> None:
93
+ self.replay_player.stop()
94
+
95
+ def next_replay_input(self) -> InputFrame:
96
+ return self.replay_player.next()
@@ -0,0 +1,125 @@
1
+ """
2
+ Capture service protocol
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from mini_arcade_core.backend import Backend
8
+ from mini_arcade_core.runtime.capture.capture_settings import CaptureSettings
9
+ from mini_arcade_core.runtime.capture.replay import (
10
+ ReplayPlayer,
11
+ ReplayRecorder,
12
+ )
13
+ from mini_arcade_core.runtime.capture.replay_format import ReplayHeader
14
+ from mini_arcade_core.runtime.capture.screenshot_capturer import (
15
+ ScreenshotCapturer,
16
+ )
17
+ from mini_arcade_core.runtime.input_frame import InputFrame
18
+
19
+
20
+ class CaptureServicePort:
21
+ """
22
+ Interface for the Capture Service.
23
+ """
24
+
25
+ backend: Backend
26
+ settings: CaptureSettings
27
+ screenshots: ScreenshotCapturer
28
+ replay_recorder: ReplayRecorder
29
+ replay_player: ReplayPlayer
30
+
31
+ # -------- screenshots --------
32
+ def screenshot(self, label: str | None = None) -> str:
33
+ """
34
+ Take a screenshot with an optional label.
35
+
36
+ :param label: Optional label for the screenshot.
37
+ :type label: str | None
38
+ :return: Path to the saved screenshot.
39
+ :rtype: str
40
+ """
41
+
42
+ def screenshot_sim(
43
+ self, run_id: str, frame_index: int, label: str = "frame"
44
+ ) -> str:
45
+ """
46
+ Take a screenshot for a simulation frame.
47
+
48
+ :param run_id: Unique identifier for the simulation run.
49
+ :type run_id: str
50
+
51
+ :param frame_index: Index of the frame in the simulation.
52
+ :type frame_index: int
53
+
54
+ :param label: Label for the screenshot.
55
+ :type label: str
56
+
57
+ :return: Path to the saved screenshot.
58
+ :rtype: str
59
+ """
60
+
61
+ # -------- replays --------
62
+ @property
63
+ def replay_playing(self) -> bool:
64
+ """
65
+ Check if a replay is currently being played back.
66
+
67
+ :return: True if a replay is active, False otherwise.
68
+ :rtype: bool
69
+ """
70
+
71
+ @property
72
+ def replay_recording(self) -> bool:
73
+ """
74
+ Check if a replay is currently being recorded.
75
+
76
+ :return: True if recording is active, False otherwise.
77
+ :rtype: bool
78
+ """
79
+
80
+ def start_replay_record(
81
+ self,
82
+ *,
83
+ filename: str,
84
+ header: ReplayHeader,
85
+ ) -> None:
86
+ """
87
+ Start recording a replay.
88
+
89
+ :param filename: The filename to save the replay to.
90
+ :type filename: str
91
+ :param header: The header information for the replay.
92
+ :type header: ReplayHeader
93
+ """
94
+
95
+ def stop_replay_record(self) -> None:
96
+ """Stop recording the current replay."""
97
+
98
+ def record_input(self, frame: InputFrame) -> None:
99
+ """
100
+ Record an input frame to the replay.
101
+
102
+ :param frame: The input frame to record.
103
+ :type frame: InputFrame
104
+ """
105
+
106
+ def start_replay_play(self, filename: str) -> ReplayHeader:
107
+ """
108
+ Start playing back a replay.
109
+
110
+ :param filename: The filename of the replay to play.
111
+ :type filename: str
112
+ :return: The header information of the replay.
113
+ :rtype: ReplayHeader
114
+ """
115
+
116
+ def stop_replay_play(self) -> None:
117
+ """Stop playing back the current replay."""
118
+
119
+ def next_replay_input(self) -> InputFrame:
120
+ """
121
+ Get the next input frame from the replay.
122
+
123
+ :return: The next input frame.
124
+ :rtype: InputFrame
125
+ """
@@ -0,0 +1,22 @@
1
+ """
2
+ Capture settings dataclass
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass
11
+ class CaptureSettings:
12
+ """
13
+ Settings for the Capture Service.
14
+
15
+ :ivar screenshots_dir: Directory to save screenshots.
16
+ :ivar screenshots_ext: File extension/format for screenshots.
17
+ :ivar replays_dir: Directory to save replays.
18
+ """
19
+
20
+ screenshots_dir: str = "screenshots"
21
+ screenshots_ext: str = "png"
22
+ replays_dir: str = "replays"
@@ -0,0 +1,174 @@
1
+ """
2
+ Capture worker thread for saving screenshots.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from queue import Empty, Queue
10
+ from threading import Event, Thread
11
+ from typing import Callable, Optional
12
+
13
+ from PIL import Image
14
+
15
+ from mini_arcade_core.utils import logger
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CaptureJob:
20
+ """
21
+ Job representing a screenshot to be saved.
22
+
23
+ :ivar job_id (str): Unique identifier for the capture job.
24
+ :ivar out_path (Path): Destination path for the saved screenshot.
25
+ :ivar bmp_path (Path): Temporary path of the bitmap image to be saved.
26
+ """
27
+
28
+ job_id: str
29
+ out_path: Path
30
+ bmp_path: Path # <-- file-based now
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class CaptureResult:
35
+ """
36
+ Result of a completed capture job.
37
+
38
+ :ivar job_id (str): Unique identifier for the capture job.
39
+ :ivar out_path (Path): Destination path where the screenshot was saved.
40
+ :ivar ok (bool): Whether the capture was successful.
41
+ :ivar error (Optional[str]): Error message if the capture failed.
42
+ """
43
+
44
+ job_id: str
45
+ out_path: Path
46
+ ok: bool
47
+ error: str | None = None
48
+
49
+
50
+ @dataclass
51
+ class WorkerConfig:
52
+ """
53
+ Configuration options for the CaptureWorker.
54
+
55
+ :ivar queue_size (int): Maximum number of jobs to queue.
56
+ :ivar on_done (Optional[Callable[[CaptureResult], None]]):
57
+ Optional callback invoked when a job is done.
58
+ :ivar name (str): Name of the worker thread.
59
+ :ivar daemon (bool): Whether the thread is a daemon thread.
60
+ :ivar delete_temp (bool): Whether to delete temporary bitmap files after saving.
61
+ """
62
+
63
+ queue_size: int = 64
64
+ on_done: Optional[Callable[[CaptureResult], None]] = None
65
+ name: str = "capture-worker"
66
+ daemon: bool = True
67
+ delete_temp: bool = True
68
+
69
+
70
+ class CaptureWorker:
71
+ """Capture worker thread for saving screenshots asynchronously."""
72
+
73
+ def __init__(
74
+ self,
75
+ worker_config: WorkerConfig | None = None,
76
+ ):
77
+ """
78
+ :param queue_size: Maximum number of jobs to queue.
79
+ :type queue_size: int
80
+ :param on_done: Optional callback invoked when a job is done.
81
+ :type on_done: Optional[Callable[[CaptureResult], None]]
82
+ :param name: Name of the worker thread.
83
+ :type name: str
84
+ :param daemon: Whether the thread is a daemon thread.
85
+ :type daemon: bool
86
+ :param delete_temp: Whether to delete temporary bitmap files after saving.
87
+ :type delete_temp: bool
88
+ """
89
+ if worker_config is None:
90
+ worker_config = WorkerConfig()
91
+ self._q: Queue[CaptureJob] = Queue(maxsize=worker_config.queue_size)
92
+ self._stop = Event()
93
+ self._thread = Thread(
94
+ target=self._run,
95
+ name=worker_config.name,
96
+ daemon=worker_config.daemon,
97
+ )
98
+ self._on_done = worker_config.on_done
99
+ self._delete_temp = worker_config.delete_temp
100
+
101
+ def start(self):
102
+ """Start the capture worker thread."""
103
+ if self._thread.is_alive():
104
+ return
105
+ self._stop.clear()
106
+ self._thread.start()
107
+
108
+ def stop(self):
109
+ """Stop the capture worker thread."""
110
+ self._stop.set()
111
+ if self._thread.is_alive():
112
+ self._thread.join(timeout=2.0)
113
+
114
+ def enqueue(self, job: CaptureJob) -> bool:
115
+ """
116
+ Enqueue a capture job.
117
+
118
+ :param job: CaptureJob to enqueue.
119
+ :type job: CaptureJob
120
+ :return: True if the job was enqueued successfully, False otherwise.
121
+ :rtype: bool
122
+ """
123
+ if self._stop.is_set():
124
+ return False
125
+ try:
126
+ self._q.put_nowait(job)
127
+ return True
128
+ # Justification: Queue.put_nowait can raise a broad exception
129
+ # pylint: disable=broad-exception-caught
130
+ except Exception:
131
+ return False
132
+ # pylint: enable=broad-exception-caught
133
+
134
+ def _run(self):
135
+ while not self._stop.is_set():
136
+ try:
137
+ job = self._q.get(timeout=0.1)
138
+ except Empty:
139
+ continue
140
+
141
+ try:
142
+ job.out_path.parent.mkdir(parents=True, exist_ok=True)
143
+
144
+ img = Image.open(str(job.bmp_path))
145
+ img.save(str(job.out_path))
146
+
147
+ if self._delete_temp:
148
+ try:
149
+ job.bmp_path.unlink(missing_ok=True)
150
+ except Exception: # pylint: disable=broad-exception-caught
151
+ logger.warning(
152
+ f"Failed to delete temp bmp: {job.bmp_path}"
153
+ )
154
+
155
+ res = CaptureResult(
156
+ job_id=job.job_id, out_path=job.out_path, ok=True
157
+ )
158
+
159
+ except Exception as exc: # pylint: disable=broad-exception-caught
160
+ logger.exception("CaptureWorker failed to save screenshot")
161
+ res = CaptureResult(
162
+ job_id=job.job_id,
163
+ out_path=job.out_path,
164
+ ok=False,
165
+ error=str(exc),
166
+ )
167
+
168
+ if self._on_done:
169
+ try:
170
+ self._on_done(res)
171
+ except Exception: # pylint: disable=broad-exception-caught
172
+ logger.warning("CaptureWorker on_done callback failed")
173
+
174
+ self._q.task_done()
@@ -0,0 +1,132 @@
1
+ """
2
+ Replay recording and playback functionality.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Iterator, Optional
10
+
11
+ from mini_arcade_core.runtime.capture.replay_format import (
12
+ ReplayHeader,
13
+ ReplayReader,
14
+ ReplayWriter,
15
+ )
16
+ from mini_arcade_core.runtime.input_frame import InputFrame
17
+
18
+
19
+ @dataclass
20
+ class ReplayRecorderConfig:
21
+ """
22
+ Configuration for replay recording.
23
+
24
+ :ivar path (Path): Path to save the replay file.
25
+ :ivar header (ReplayHeader): Header information for the replay.
26
+ """
27
+
28
+ path: Path
29
+ header: ReplayHeader
30
+
31
+
32
+ class ReplayRecorder:
33
+ """Recorder for game replays."""
34
+
35
+ def __init__(self):
36
+ self._writer: Optional[ReplayWriter] = None
37
+
38
+ @property
39
+ def active(self) -> bool:
40
+ """
41
+ Check if the recorder is currently active.
42
+
43
+ :return: True if recording is active, False otherwise.
44
+ :rtype: bool
45
+ """
46
+ return self._writer is not None
47
+
48
+ def start(self, cfg: ReplayRecorderConfig) -> None:
49
+ """
50
+ Start recording a replay.
51
+
52
+ :param cfg: Configuration for the replay recorder.
53
+ :type cfg: ReplayRecorderConfig
54
+ """
55
+ if self._writer:
56
+ raise RuntimeError("ReplayRecorder already active")
57
+ self._writer = ReplayWriter(cfg.path, cfg.header)
58
+ self._writer.open()
59
+
60
+ def record(self, frame: InputFrame) -> None:
61
+ """
62
+ Record an input frame to the replay.
63
+
64
+ :param frame: The input frame to record.
65
+ :type frame: InputFrame
66
+ """
67
+ if self._writer:
68
+ self._writer.write_frame(frame)
69
+
70
+ def stop(self) -> None:
71
+ """Stop recording the current replay."""
72
+ if self._writer:
73
+ self._writer.close()
74
+ self._writer = None
75
+
76
+
77
+ class ReplayPlayer:
78
+ """Player for game replays."""
79
+
80
+ def __init__(self):
81
+ self._reader: Optional[ReplayReader] = None
82
+ self._it: Optional[Iterator[InputFrame]] = None
83
+ self.header: Optional[ReplayHeader] = None
84
+
85
+ @property
86
+ def active(self) -> bool:
87
+ """
88
+ Check if the player is currently active.
89
+
90
+ :return: True if a replay is being played, False otherwise.
91
+ :rtype: bool
92
+ """
93
+ return self._it is not None
94
+
95
+ def start(self, path: Path) -> ReplayHeader:
96
+ """
97
+ Start playing back a replay.
98
+
99
+ :param path: Path to the replay file.
100
+ :type path: Path
101
+ :return: The header information of the replay.
102
+ :rtype: ReplayHeader
103
+ """
104
+ if self._reader:
105
+ raise RuntimeError("ReplayPlayer already active")
106
+ self._reader = ReplayReader(path)
107
+ self.header = self._reader.open()
108
+ self._it = self._reader.frames()
109
+ return self.header
110
+
111
+ def next(self) -> InputFrame:
112
+ """
113
+ Get the next input frame from the replay.
114
+
115
+ :return: The next input frame.
116
+ :rtype: InputFrame
117
+ """
118
+ if not self._it:
119
+ raise RuntimeError("ReplayPlayer not active")
120
+ try:
121
+ return next(self._it)
122
+ except StopIteration as exc:
123
+ self.stop()
124
+ raise RuntimeError("Replay finished") from exc
125
+
126
+ def stop(self) -> None:
127
+ """Stop playing back the current replay."""
128
+ if self._reader:
129
+ self._reader.close()
130
+ self._reader = None
131
+ self._it = None
132
+ self.header = None
@@ -0,0 +1,120 @@
1
+ """
2
+ Replay file format for mini-arcade.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ from dataclasses import asdict, dataclass
9
+ from pathlib import Path
10
+ from typing import Iterator, Optional, TextIO
11
+
12
+ from mini_arcade_core.runtime.input_frame import InputFrame
13
+
14
+ REPLAY_MAGIC = "mini-arcade-replay"
15
+ REPLAY_VERSION = 1
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class ReplayHeader:
20
+ """
21
+ Header information for a mini-arcade replay file.
22
+
23
+ :ivar magic: Magic string to identify the file type.
24
+ :ivar version: Version of the replay format.
25
+ :ivar game_id: Identifier for the game.
26
+ :ivar initial_scene: Name of the initial scene.
27
+ :ivar seed: Seed used for random number generation.
28
+ :ivar fps: Frames per second of the replay.
29
+ """
30
+
31
+ magic: str = REPLAY_MAGIC
32
+ version: int = REPLAY_VERSION
33
+ game_id: str = "unknown"
34
+ initial_scene: str = "unknown"
35
+ seed: int = 0
36
+ fps: int = 60
37
+
38
+
39
+ class ReplayWriter:
40
+ """Replay file writer."""
41
+
42
+ def __init__(self, path: Path, header: ReplayHeader):
43
+ """
44
+ :param path: Path to save the replay file.
45
+ :type path: Path
46
+ :param header: Header information for the replay.
47
+ :type header: ReplayHeader
48
+ """
49
+ self.path = path
50
+ self.header = header
51
+ self._f: Optional[TextIO] = None
52
+
53
+ def open(self) -> None:
54
+ """Open the replay file for writing."""
55
+ self.path.parent.mkdir(parents=True, exist_ok=True)
56
+ self._f = self.path.open("w", encoding="utf-8")
57
+ self._f.write(json.dumps(asdict(self.header)) + "\n")
58
+
59
+ def write_frame(self, frame: InputFrame) -> None:
60
+ """
61
+ Write an input frame to the replay file.
62
+
63
+ :param frame: Input frame to write.
64
+ :type frame: InputFrame
65
+ """
66
+ if not self._f:
67
+ raise RuntimeError("ReplayWriter is not open")
68
+ self._f.write(json.dumps(frame.to_dict()) + "\n")
69
+
70
+ def close(self) -> None:
71
+ """Close the replay file."""
72
+ if self._f:
73
+ self._f.close()
74
+ self._f = None
75
+
76
+
77
+ class ReplayReader:
78
+ """Replay file reader."""
79
+
80
+ def __init__(self, path: Path):
81
+ """
82
+ :param path: Path to the replay file.
83
+ :type path: Path"""
84
+ self.path = path
85
+ self.header: Optional[ReplayHeader] = None
86
+ self._f: Optional[TextIO] = None
87
+
88
+ def open(self) -> ReplayHeader:
89
+ """
90
+ Open the replay file for reading.
91
+
92
+ :return: The replay header.
93
+ :rtype: ReplayHeader
94
+ """
95
+ self._f = self.path.open("r", encoding="utf-8")
96
+ header_line = self._f.readline()
97
+ self.header = ReplayHeader(**json.loads(header_line))
98
+ if self.header.magic != REPLAY_MAGIC:
99
+ raise ValueError("Not a mini-arcade replay file")
100
+ return self.header
101
+
102
+ def frames(self) -> Iterator[InputFrame]:
103
+ """
104
+ Iterate over input frames in the replay file.
105
+
106
+ :return: Input frames from the replay.
107
+ :rtype: Iterator[InputFrame]
108
+ """
109
+ if not self._f:
110
+ raise RuntimeError("ReplayReader is not open")
111
+ for line in self._f:
112
+ if not line.strip():
113
+ continue
114
+ yield InputFrame.from_dict(json.loads(line))
115
+
116
+ def close(self) -> None:
117
+ """Close the replay file."""
118
+ if self._f:
119
+ self._f.close()
120
+ self._f = None
@@ -8,11 +8,16 @@ from dataclasses import dataclass
8
8
  from datetime import datetime
9
9
  from pathlib import Path
10
10
  from typing import Optional
11
+ from uuid import uuid4
11
12
 
12
13
  from PIL import Image
13
14
 
14
15
  from mini_arcade_core.backend import Backend
15
16
  from mini_arcade_core.runtime.capture.capture_port import CapturePort
17
+ from mini_arcade_core.runtime.capture.capture_worker import (
18
+ CaptureJob,
19
+ CaptureWorker,
20
+ )
16
21
  from mini_arcade_core.utils import logger
17
22
 
18
23
 
@@ -73,7 +78,7 @@ class CapturePathBuilder:
73
78
  return Path(self.directory) / run_id / name
74
79
 
75
80
 
76
- class CaptureAdapter(CapturePort):
81
+ class ScreenshotCapturer(CapturePort):
77
82
  """
78
83
  Adapter for capturing frames.
79
84
 
@@ -87,9 +92,12 @@ class CaptureAdapter(CapturePort):
87
92
  self,
88
93
  backend: Backend,
89
94
  path_builder: Optional[CapturePathBuilder] = None,
95
+ worker: Optional[CaptureWorker] = None,
90
96
  ):
91
97
  self.backend = backend
92
98
  self.path_builder = path_builder or CapturePathBuilder()
99
+ self.worker = worker or CaptureWorker()
100
+ self.worker.start()
93
101
 
94
102
  def _bmp_to_image(self, bmp_path: str, out_path: str):
95
103
  img = Image.open(bmp_path)
@@ -98,25 +106,7 @@ class CaptureAdapter(CapturePort):
98
106
  def screenshot(self, label: str | None = None) -> str:
99
107
  label = label or "shot"
100
108
  out_path = self.path_builder.build(label)
101
- out_path.parent.mkdir(parents=True, exist_ok=True)
102
-
103
- # If backend only saves BMP, capture to a temp bmp next to output
104
- bmp_path = out_path.with_suffix(".bmp")
105
-
106
- self.backend.capture.bmp(str(bmp_path))
107
- if not bmp_path.exists():
108
- raise RuntimeError("Backend capture.bmp did not create BMP file")
109
-
110
- self._bmp_to_image(str(bmp_path), str(out_path))
111
- try:
112
- bmp_path.unlink(missing_ok=True)
113
- # Justification: Various exceptions can occur on file deletion
114
- # pylint: disable=broad-exception-caught
115
- except Exception:
116
- logger.warning(f"Failed to delete temporary BMP file: {bmp_path}")
117
- # pylint: enable=broad-exception-caught
118
-
119
- return str(out_path)
109
+ return self._capture_to(out_path, job_id=uuid4().hex)
120
110
 
121
111
  def screenshot_bytes(self) -> bytes:
122
112
  data = self.backend.capture.bmp(path=None)
@@ -127,24 +117,28 @@ class CaptureAdapter(CapturePort):
127
117
  def screenshot_sim(
128
118
  self, run_id: str, frame_index: int, label: str = "frame"
129
119
  ) -> str:
130
- """Screenshot for simulation frames."""
131
120
  out_path = self.path_builder.build_sim(run_id, frame_index, label)
132
- out_path.parent.mkdir(parents=True, exist_ok=True)
133
-
134
- bmp_path = out_path.with_suffix(".bmp")
135
- self.backend.capture.bmp(str(bmp_path))
121
+ return self._capture_to(out_path, job_id=f"{run_id}:{frame_index}")
136
122
 
137
- if not bmp_path.exists():
138
- raise RuntimeError("Backend capture.bmp did not create BMP file")
123
+ def _capture_to(self, out_path: Path, job_id: str) -> str:
124
+ out_path.parent.mkdir(parents=True, exist_ok=True)
139
125
 
140
- self._bmp_to_image(str(bmp_path), str(out_path))
126
+ bmp_path = out_path.with_suffix(f".{uuid4().hex}.bmp")
127
+ bmp_path.parent.mkdir(parents=True, exist_ok=True)
141
128
 
142
- try:
143
- bmp_path.unlink(missing_ok=True)
144
- # Justification: Various exceptions can occur on file deletion
145
- # pylint: disable=broad-exception-caught
146
- except Exception:
147
- logger.warning(f"Failed to delete temporary BMP file: {bmp_path}")
148
- # pylint: enable=broad-exception-caught
129
+ ok_native = self.backend.capture.bmp(str(bmp_path))
130
+ if not ok_native or not bmp_path.exists():
131
+ raise RuntimeError("Backend capture.bmp failed to create BMP file")
149
132
 
133
+ ok = self.worker.enqueue(
134
+ CaptureJob(job_id=job_id, out_path=out_path, bmp_path=bmp_path)
135
+ )
136
+ if not ok:
137
+ logger.warning("Screenshot dropped: capture queue full")
138
+ try:
139
+ bmp_path.unlink(missing_ok=True)
140
+ except Exception: # pylint: disable=broad-exception-caught
141
+ pass
142
+
143
+ # IMPORTANT: async, so it's "queued"
150
144
  return str(out_path)
@@ -4,7 +4,7 @@ Input frame data structure for capturing input state per frame.
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- from dataclasses import dataclass, field
7
+ from dataclasses import asdict, dataclass, field
8
8
  from typing import Dict, FrozenSet, Tuple
9
9
 
10
10
  from mini_arcade_core.backend.keys import Key
@@ -24,6 +24,32 @@ class ButtonState:
24
24
  pressed: bool
25
25
  released: bool
26
26
 
27
+ def to_dict(self) -> Dict[str, bool]:
28
+ """
29
+ Convert the ButtonState to a dictionary.
30
+
31
+ :return: Dictionary representation of the ButtonState.
32
+ :rtype: Dict[str, bool]
33
+ """
34
+ return asdict(self)
35
+
36
+ @classmethod
37
+ def from_dict(cls, data: Dict[str, bool]) -> ButtonState:
38
+ """
39
+ Create a ButtonState from a dictionary.
40
+
41
+ :param data: Dictionary containing button state data.
42
+ :type data: Dict[str, bool]
43
+
44
+ :return: ButtonState instance.
45
+ :rtype: ButtonState
46
+ """
47
+ return cls(
48
+ down=data.get("down", False),
49
+ pressed=data.get("pressed", False),
50
+ released=data.get("released", False),
51
+ )
52
+
27
53
 
28
54
  # TODO: Solve too-many-instance-attributes warning later
29
55
  # Justification: This data class needs multiple attributes to capture input state.
@@ -67,5 +93,57 @@ class InputFrame:
67
93
  # Window/OS quit request
68
94
  quit: bool = False
69
95
 
96
+ def to_dict(self) -> Dict[str, object]:
97
+ """
98
+ Convert the InputFrame to a dictionary.
99
+
100
+ :return: Dictionary representation of the InputFrame.
101
+ :rtype: Dict[str, object]
102
+ """
103
+ data = asdict(self)
104
+
105
+ # Convert ButtonState objects to dicts
106
+ data["buttons"] = {
107
+ name: state.to_dict() for name, state in self.buttons.items()
108
+ }
109
+
110
+ # Convert FrozenSet to list for serialization
111
+ data["keys_down"] = [k.value for k in self.keys_down]
112
+ data["keys_pressed"] = [k.value for k in self.keys_pressed]
113
+ data["keys_released"] = [k.value for k in self.keys_released]
114
+ return data
115
+
116
+ @classmethod
117
+ def from_dict(cls, data: Dict[str, object]) -> InputFrame:
118
+ """
119
+ Create an InputFrame from a dictionary.
120
+
121
+ :param data: Dictionary containing input frame data.
122
+ :type data: Dict[str, object]
123
+
124
+ :return: InputFrame instance.
125
+ :rtype: InputFrame
126
+ """
127
+ return cls(
128
+ frame_index=data.get("frame_index", 0),
129
+ dt=data.get("dt", 0.0),
130
+ keys_down=frozenset(Key(v) for v in data.get("keys_down", [])),
131
+ keys_pressed=frozenset(
132
+ Key(v) for v in data.get("keys_pressed", [])
133
+ ),
134
+ keys_released=frozenset(
135
+ Key(v) for v in data.get("keys_released", [])
136
+ ),
137
+ buttons={
138
+ name: ButtonState.from_dict(state)
139
+ for name, state in data.get("buttons", {}).items()
140
+ },
141
+ axes=data.get("axes", {}),
142
+ mouse_pos=tuple(data.get("mouse_pos", (0, 0))),
143
+ mouse_delta=tuple(data.get("mouse_delta", (0, 0))),
144
+ text_input=data.get("text_input", ""),
145
+ quit=data.get("quit", False),
146
+ )
147
+
70
148
 
71
149
  # pylint: enable=too-many-instance-attributes
@@ -7,7 +7,9 @@ from __future__ import annotations
7
7
  from dataclasses import dataclass
8
8
 
9
9
  from mini_arcade_core.runtime.audio.audio_port import AudioPort
10
- from mini_arcade_core.runtime.capture.capture_port import CapturePort
10
+ from mini_arcade_core.runtime.capture.capture_service_protocol import (
11
+ CaptureServicePort,
12
+ )
11
13
  from mini_arcade_core.runtime.file.file_port import FilePort
12
14
  from mini_arcade_core.runtime.input.input_port import InputPort
13
15
  from mini_arcade_core.runtime.render.render_port import RenderServicePort
@@ -24,7 +26,7 @@ class RuntimeServices:
24
26
  :ivar scenes (ScenePort): Scene management service port.
25
27
  :ivar audio (AudioPort): Audio service port.
26
28
  :ivar files (FilePort): File service port.
27
- :ivar capture (CapturePort): Capture service port.
29
+ :ivar capture (CaptureServicePort): Capture service port.
28
30
  :ivar input (InputPort): Input handling service port.
29
31
  :ivar render (RenderServicePort): Rendering service port.
30
32
  """
@@ -32,7 +34,7 @@ class RuntimeServices:
32
34
  window: WindowPort
33
35
  audio: AudioPort
34
36
  files: FilePort
35
- capture: CapturePort
37
+ capture: CaptureServicePort
36
38
  input: InputPort
37
39
  render: RenderServicePort
38
40
  scenes: SceneQueryPort
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-arcade-core
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: Tiny scene-based game loop core for small arcade games.
5
5
  License: Copyright (c) 2025 Santiago Rincón
6
6
 
@@ -8,14 +8,14 @@ mini_arcade_core/backend/types.py,sha256=EW0bW4MvsEZKot0Z1h_5LuFSzoYGiJBphTquBz4
8
8
  mini_arcade_core/bus.py,sha256=2Etpoa-UWhk33xJjqDlY5YslPDJEjxNoIEVtF3C73vs,1558
9
9
  mini_arcade_core/engine/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
10
  mini_arcade_core/engine/cheats.py,sha256=jMx2a8YnaNCkCG5MPmIzz4uHuS7-_aYf0J45cv2-3v0,5569
11
- mini_arcade_core/engine/commands.py,sha256=Cw1tAwVRO5U2--hFX1Jq00LH_84oe1Oqw-Ngc0RnkGI,5383
12
- mini_arcade_core/engine/game.py,sha256=DDdvlv0cN4sjBKtoIuP19W3YQCF1LwZbC_jYCz-1E4U,5767
11
+ mini_arcade_core/engine/commands.py,sha256=oe5VWpFtIEdxQu2m5_pFXnwY1LJ3uhv8wOP27t01tfI,7400
12
+ mini_arcade_core/engine/game.py,sha256=DikhhLK2byDbMxSZH0TsjZOR_qY3mfR3EhXa0J79FX4,5812
13
13
  mini_arcade_core/engine/game_config.py,sha256=4AP8n0Uk1HKEdPLOrV1xsySzBljAh8VhZASNrxPIMMc,1034
14
14
  mini_arcade_core/engine/gameplay_settings.py,sha256=W8WBwfAvGZftkL4aMnOTx6SsGxwG-9Ou1Ey0AeWPCxs,549
15
15
  mini_arcade_core/engine/loop/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
16
  mini_arcade_core/engine/loop/config.py,sha256=Sj1LrdnD_aACmHUuRQuKB7bDbDZy60WVe8sJHRmzmIU,461
17
17
  mini_arcade_core/engine/loop/hooks.py,sha256=nmZi-35iMsZoPedTdZzsIKJP6O_iFUeKjB8xc4-XTHU,2417
18
- mini_arcade_core/engine/loop/runner.py,sha256=uB4onDMO6lWFt40YNJsovkVHXiyaVdEuEFj8kYqWn6k,8858
18
+ mini_arcade_core/engine/loop/runner.py,sha256=M4M-V0En4MIlUbWXtbCCs24PWDNIWcWEiyI86xhcyeE,9652
19
19
  mini_arcade_core/engine/loop/state.py,sha256=fzXQ9GP05PVNXEBTgIwA4qjMujxdUae3CXM6uRQz92Y,858
20
20
  mini_arcade_core/engine/managers.py,sha256=eQJYe-xYtRha-FWxzJ3DcpwlcHwiT5sGt4oCD9ZPxEE,664
21
21
  mini_arcade_core/engine/render/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
@@ -46,8 +46,14 @@ mini_arcade_core/runtime/audio/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5
46
46
  mini_arcade_core/runtime/audio/audio_adapter.py,sha256=lnP35txPzSKX1_il0nXcK7RMF5Qp9Qhi9YMh_7LTdPM,588
47
47
  mini_arcade_core/runtime/audio/audio_port.py,sha256=jBd9WabN41uK3MHjg_1n4AOw83NivJlGE2m430WZTnk,831
48
48
  mini_arcade_core/runtime/capture/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
49
- mini_arcade_core/runtime/capture/capture_adapter.py,sha256=XBtiKw3AS2dzB4QogPm9kjhiQAenS25guX87tg-zK58,4882
50
49
  mini_arcade_core/runtime/capture/capture_port.py,sha256=niHi0pAo10mC9p73FxFkYBIGLOLRN0PiOvxE4Zgo5fM,1162
50
+ mini_arcade_core/runtime/capture/capture_service.py,sha256=9ig0Wi0OZisefUDse7-v-_SUR1IPGAQmc15S2PZz-rY,2917
51
+ mini_arcade_core/runtime/capture/capture_service_protocol.py,sha256=f17yKG7vPluITZyjCutsGsYlm-FAKE0zgcRhXD-wtUM,3308
52
+ mini_arcade_core/runtime/capture/capture_settings.py,sha256=vDFdZi1HAF10cwrgM-46oVSu4Q5dk-p2F-4-TDBq0Ko,479
53
+ mini_arcade_core/runtime/capture/capture_worker.py,sha256=XAzL28jD8jnGoG3bvsd9yQW5S4Nv7licNJn94QTIzp4,5393
54
+ mini_arcade_core/runtime/capture/replay.py,sha256=oDKY5oDLJS2PU9F57CinDMkDsLJui9V67t-jsjgVyWE,3471
55
+ mini_arcade_core/runtime/capture/replay_format.py,sha256=VekOS4A-N8zrL_m7ByuqTZqMNxAoN9EFStRpSkSnk_k,3387
56
+ mini_arcade_core/runtime/capture/screenshot_capturer.py,sha256=_pDg1U0587MocyADH2b2IpoN8t3dc2XdjrOOz115c20,4652
51
57
  mini_arcade_core/runtime/context.py,sha256=ONKQryO3KEOOqHaByxCUola07kdjrnvr4WfXwgwTobk,1777
52
58
  mini_arcade_core/runtime/file/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
53
59
  mini_arcade_core/runtime/file/file_adapter.py,sha256=09q7G9Qijml9d4AAjo6HLC1yuoVTjE_7xaT8apT4mk0,523
@@ -55,13 +61,13 @@ mini_arcade_core/runtime/file/file_port.py,sha256=p1MouCSHXZw--rWNMw3aYBLU-of8mX
55
61
  mini_arcade_core/runtime/input/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
56
62
  mini_arcade_core/runtime/input/input_adapter.py,sha256=vExQiwFIWTI3zYD8lmnD9TvoQPZvJfI6IINPJUqAdQ0,1467
57
63
  mini_arcade_core/runtime/input/input_port.py,sha256=d4ptftwf92_LJdyaUMFxIsLHXBINzQyJACHn4laNyxQ,746
58
- mini_arcade_core/runtime/input_frame.py,sha256=34-RAfOD-YScVLyRQrarpm7byFTHjsWM77lIH0JsmT8,2384
64
+ mini_arcade_core/runtime/input_frame.py,sha256=DnikpT7LP-HUETnHG6Kan_j11bBXAQyf8XqW6O2FZhc,4948
59
65
  mini_arcade_core/runtime/render/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
60
66
  mini_arcade_core/runtime/render/render_port.py,sha256=Sqp-JBh-iRzzGtgnO_nU1KiJEqyrTYPRDQbg04HdR0A,507
61
67
  mini_arcade_core/runtime/scene/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
62
68
  mini_arcade_core/runtime/scene/scene_query_adapter.py,sha256=FNkqXgwxfugX_xqqFlZl0ELXsrW_gco5Au0tJhMGLgQ,909
63
69
  mini_arcade_core/runtime/scene/scene_query_port.py,sha256=qTikQVxOkJCdoMoH_lbe_ctJj7SWeJnnqDo6Ee0N_pQ,1019
64
- mini_arcade_core/runtime/services.py,sha256=iYcXt2CTapgDzSb54DsPasYZ4jTN7tA_B0lV1Sl5b1g,1243
70
+ mini_arcade_core/runtime/services.py,sha256=GE9e-rYL62NwdV6haMrSLqXYhZrLDsAAlIsIPJncW6E,1285
65
71
  mini_arcade_core/runtime/window/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
66
72
  mini_arcade_core/runtime/window/window_adapter.py,sha256=VLZGYBVl7sGMmnk5mVowDleTyciAfE-Tc2woNFvRrgE,2890
67
73
  mini_arcade_core/runtime/window/window_port.py,sha256=HBy2OjsZzlxbBDQiTqlKEbIaejpN1zDp5whgvKxZxaY,2322
@@ -86,7 +92,7 @@ mini_arcade_core/utils/__init__.py,sha256=id1C0au8r1oIzGha42xXwnI9ojcU1hxPgto6QS
86
92
  mini_arcade_core/utils/deprecated_decorator.py,sha256=yrrW2ZqPskK-4MUTyIrMb465Wc54X2poV53ZQutZWqc,1140
87
93
  mini_arcade_core/utils/logging.py,sha256=ygKpey6nikp30PrNDP_yRs8pxPPRbsQ0ivR6LUuEn3Q,6413
88
94
  mini_arcade_core/utils/profiler.py,sha256=vLzrxDfAplgKGxpuzk4eFJx4t5DU5M3DQAn6sfS5D_4,8733
89
- mini_arcade_core-1.2.0.dist-info/METADATA,sha256=PeD3ImQ4FG5bjuYKFdLThR59fOvr-lgOS3OaF1XVRis,8188
90
- mini_arcade_core-1.2.0.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
91
- mini_arcade_core-1.2.0.dist-info/licenses/LICENSE,sha256=3lHAuV0584cVS5vAqi2uC6GcsVgxUijvwvtZckyvaZ4,1096
92
- mini_arcade_core-1.2.0.dist-info/RECORD,,
95
+ mini_arcade_core-1.2.2.dist-info/METADATA,sha256=uTEmFUmLixZzpR4AfgIe9dZsT6T09VpahwGipIcWRWU,8188
96
+ mini_arcade_core-1.2.2.dist-info/WHEEL,sha256=kJCRJT_g0adfAJzTx2GUMmS80rTJIVHRCfG0DQgLq3o,88
97
+ mini_arcade_core-1.2.2.dist-info/licenses/LICENSE,sha256=3lHAuV0584cVS5vAqi2uC6GcsVgxUijvwvtZckyvaZ4,1096
98
+ mini_arcade_core-1.2.2.dist-info/RECORD,,