crimsonland 0.1.0.dev5__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.
Files changed (139) hide show
  1. crimson/__init__.py +24 -0
  2. crimson/assets_fetch.py +60 -0
  3. crimson/atlas.py +92 -0
  4. crimson/audio_router.py +155 -0
  5. crimson/bonuses.py +167 -0
  6. crimson/camera.py +75 -0
  7. crimson/cli.py +380 -0
  8. crimson/creatures/__init__.py +8 -0
  9. crimson/creatures/ai.py +186 -0
  10. crimson/creatures/anim.py +173 -0
  11. crimson/creatures/damage.py +103 -0
  12. crimson/creatures/runtime.py +1019 -0
  13. crimson/creatures/spawn.py +2871 -0
  14. crimson/debug.py +7 -0
  15. crimson/demo.py +1360 -0
  16. crimson/demo_trial.py +140 -0
  17. crimson/effects.py +1086 -0
  18. crimson/effects_atlas.py +73 -0
  19. crimson/frontend/__init__.py +1 -0
  20. crimson/frontend/assets.py +43 -0
  21. crimson/frontend/boot.py +424 -0
  22. crimson/frontend/menu.py +700 -0
  23. crimson/frontend/panels/__init__.py +1 -0
  24. crimson/frontend/panels/base.py +410 -0
  25. crimson/frontend/panels/controls.py +132 -0
  26. crimson/frontend/panels/mods.py +128 -0
  27. crimson/frontend/panels/options.py +409 -0
  28. crimson/frontend/panels/play_game.py +627 -0
  29. crimson/frontend/panels/stats.py +351 -0
  30. crimson/frontend/transitions.py +31 -0
  31. crimson/game.py +2533 -0
  32. crimson/game_modes.py +15 -0
  33. crimson/game_world.py +652 -0
  34. crimson/gameplay.py +2467 -0
  35. crimson/input_codes.py +176 -0
  36. crimson/modes/__init__.py +1 -0
  37. crimson/modes/base_gameplay_mode.py +219 -0
  38. crimson/modes/quest_mode.py +502 -0
  39. crimson/modes/rush_mode.py +300 -0
  40. crimson/modes/survival_mode.py +792 -0
  41. crimson/modes/tutorial_mode.py +648 -0
  42. crimson/modes/typo_mode.py +472 -0
  43. crimson/paths.py +23 -0
  44. crimson/perks.py +828 -0
  45. crimson/persistence/__init__.py +1 -0
  46. crimson/persistence/highscores.py +385 -0
  47. crimson/persistence/save_status.py +245 -0
  48. crimson/player_damage.py +77 -0
  49. crimson/projectiles.py +1133 -0
  50. crimson/quests/__init__.py +18 -0
  51. crimson/quests/helpers.py +147 -0
  52. crimson/quests/registry.py +49 -0
  53. crimson/quests/results.py +164 -0
  54. crimson/quests/runtime.py +91 -0
  55. crimson/quests/tier1.py +620 -0
  56. crimson/quests/tier2.py +652 -0
  57. crimson/quests/tier3.py +579 -0
  58. crimson/quests/tier4.py +721 -0
  59. crimson/quests/tier5.py +886 -0
  60. crimson/quests/timeline.py +115 -0
  61. crimson/quests/types.py +70 -0
  62. crimson/render/__init__.py +1 -0
  63. crimson/render/terrain_fx.py +88 -0
  64. crimson/render/world_renderer.py +1941 -0
  65. crimson/sim/__init__.py +1 -0
  66. crimson/sim/world_defs.py +67 -0
  67. crimson/sim/world_state.py +422 -0
  68. crimson/terrain_assets.py +19 -0
  69. crimson/tutorial/__init__.py +12 -0
  70. crimson/tutorial/timeline.py +291 -0
  71. crimson/typo/__init__.py +2 -0
  72. crimson/typo/names.py +233 -0
  73. crimson/typo/player.py +43 -0
  74. crimson/typo/spawns.py +73 -0
  75. crimson/typo/typing.py +52 -0
  76. crimson/ui/__init__.py +3 -0
  77. crimson/ui/cursor.py +95 -0
  78. crimson/ui/demo_trial_overlay.py +235 -0
  79. crimson/ui/game_over.py +660 -0
  80. crimson/ui/hud.py +601 -0
  81. crimson/ui/perk_menu.py +388 -0
  82. crimson/views/__init__.py +40 -0
  83. crimson/views/aim_debug.py +276 -0
  84. crimson/views/animations.py +274 -0
  85. crimson/views/arsenal_debug.py +404 -0
  86. crimson/views/audio_bootstrap.py +47 -0
  87. crimson/views/bonuses.py +201 -0
  88. crimson/views/camera_debug.py +359 -0
  89. crimson/views/camera_shake.py +229 -0
  90. crimson/views/corpse_stamp_debug.py +324 -0
  91. crimson/views/decals_debug.py +739 -0
  92. crimson/views/empty.py +19 -0
  93. crimson/views/fonts.py +114 -0
  94. crimson/views/game_over.py +117 -0
  95. crimson/views/ground.py +259 -0
  96. crimson/views/lighting_debug.py +1166 -0
  97. crimson/views/particles.py +293 -0
  98. crimson/views/perk_menu_debug.py +430 -0
  99. crimson/views/perks.py +398 -0
  100. crimson/views/player.py +434 -0
  101. crimson/views/player_sprite_debug.py +314 -0
  102. crimson/views/projectile_fx.py +609 -0
  103. crimson/views/projectile_render_debug.py +393 -0
  104. crimson/views/projectiles.py +221 -0
  105. crimson/views/quest_title_overlay.py +108 -0
  106. crimson/views/registry.py +34 -0
  107. crimson/views/rush.py +16 -0
  108. crimson/views/small_font_debug.py +204 -0
  109. crimson/views/spawn_plan.py +363 -0
  110. crimson/views/sprites.py +214 -0
  111. crimson/views/survival.py +15 -0
  112. crimson/views/terrain.py +132 -0
  113. crimson/views/ui.py +123 -0
  114. crimson/views/wicons.py +166 -0
  115. crimson/weapon_sfx.py +63 -0
  116. crimson/weapons.py +860 -0
  117. crimsonland-0.1.0.dev5.dist-info/METADATA +9 -0
  118. crimsonland-0.1.0.dev5.dist-info/RECORD +139 -0
  119. crimsonland-0.1.0.dev5.dist-info/WHEEL +4 -0
  120. crimsonland-0.1.0.dev5.dist-info/entry_points.txt +4 -0
  121. grim/__init__.py +20 -0
  122. grim/app.py +92 -0
  123. grim/assets.py +231 -0
  124. grim/audio.py +106 -0
  125. grim/config.py +294 -0
  126. grim/console.py +737 -0
  127. grim/fonts/__init__.py +7 -0
  128. grim/fonts/grim_mono.py +111 -0
  129. grim/fonts/small.py +120 -0
  130. grim/input.py +44 -0
  131. grim/jaz.py +103 -0
  132. grim/math.py +17 -0
  133. grim/music.py +403 -0
  134. grim/paq.py +76 -0
  135. grim/rand.py +37 -0
  136. grim/sfx.py +276 -0
  137. grim/sfx_map.py +103 -0
  138. grim/terrain_render.py +840 -0
  139. grim/view.py +16 -0
grim/music.py ADDED
@@ -0,0 +1,403 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+ import random
7
+
8
+ import pyray as rl
9
+
10
+ from .console import ConsoleState
11
+ from . import paq
12
+
13
+
14
+ MUSIC_PAK_NAME = "music.paq"
15
+ MUSIC_TRACKS: dict[str, tuple[str, ...]] = {
16
+ "intro": ("music/intro.ogg", "intro.ogg"),
17
+ "shortie_monk": ("music/shortie_monk.ogg", "shortie_monk.ogg"),
18
+ "crimson_theme": ("music/crimson_theme.ogg", "crimson_theme.ogg"),
19
+ "crimsonquest": ("music/crimsonquest.ogg", "crimsonquest.ogg"),
20
+ "gt1_ingame": ("music/gt1_ingame.ogg", "gt1_ingame.ogg"),
21
+ "gt2_harppen": ("music/gt2_harppen.ogg", "gt2_harppen.ogg"),
22
+ }
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class MusicState:
27
+ ready: bool
28
+ enabled: bool
29
+ volume: float
30
+ tracks: dict[str, rl.Music]
31
+ active_track: str | None
32
+ playbacks: dict[str, "TrackPlayback"] = field(default_factory=dict)
33
+ queue: list[str] = field(default_factory=list)
34
+ # Mirrors the original game's "start a random game tune on first hit" gate.
35
+ game_tune_started: bool = False
36
+ game_tune_track: str | None = None
37
+ track_ids: dict[str, int] = field(default_factory=dict)
38
+ next_track_id: int = 0
39
+ paq_entries: dict[str, bytes] | None = None
40
+
41
+
42
+ def init_music_state(*, ready: bool, enabled: bool, volume: float) -> MusicState:
43
+ return MusicState(
44
+ ready=ready,
45
+ enabled=enabled,
46
+ volume=float(volume),
47
+ tracks={},
48
+ active_track=None,
49
+ playbacks={},
50
+ queue=[],
51
+ game_tune_started=False,
52
+ game_tune_track=None,
53
+ track_ids={},
54
+ next_track_id=0,
55
+ paq_entries=None,
56
+ )
57
+
58
+
59
+ def load_music_tracks(state: MusicState, assets_dir: Path, console: ConsoleState) -> None:
60
+ if not state.ready or not state.enabled:
61
+ return
62
+
63
+ music_dir = assets_dir / "music"
64
+ if music_dir.exists() and music_dir.is_dir():
65
+ loaded = 0
66
+ for track_name, candidates in MUSIC_TRACKS.items():
67
+ music = None
68
+ for candidate in candidates:
69
+ path = assets_dir / candidate
70
+ if not path.exists():
71
+ continue
72
+ music = rl.load_music_stream(str(path))
73
+ if music is not None:
74
+ break
75
+ if music is None:
76
+ raise FileNotFoundError(f"audio: missing music file for track '{track_name}' in {music_dir}")
77
+ rl.set_music_volume(music, state.volume)
78
+ state.tracks[track_name] = music
79
+ loaded += 1
80
+ state.track_ids = {name: idx for idx, name in enumerate(state.tracks.keys())}
81
+ state.next_track_id = len(state.track_ids)
82
+ state.paq_entries = None
83
+ console.log.log(f"audio: music tracks loaded {loaded}/{len(MUSIC_TRACKS)} from {music_dir}")
84
+ console.log.flush()
85
+ return
86
+
87
+ paq_path = assets_dir / MUSIC_PAK_NAME
88
+ if not paq_path.exists():
89
+ raise FileNotFoundError(f"audio: missing {MUSIC_PAK_NAME} in {assets_dir}")
90
+
91
+ entries: dict[str, bytes] = {}
92
+ for name, data in paq.iter_entries(paq_path):
93
+ entries[name.replace("\\", "/")] = data
94
+
95
+ loaded = 0
96
+ for track_name, candidates in MUSIC_TRACKS.items():
97
+ data = None
98
+ for candidate in candidates:
99
+ data = entries.get(candidate)
100
+ if data is not None:
101
+ break
102
+ if data is None:
103
+ raise FileNotFoundError(f"audio: missing music entry for track '{track_name}' in {MUSIC_PAK_NAME}")
104
+ music = rl.load_music_stream_from_memory(".ogg", data, len(data))
105
+ rl.set_music_volume(music, state.volume)
106
+ state.tracks[track_name] = music
107
+ loaded += 1
108
+ state.track_ids = {name: idx for idx, name in enumerate(state.tracks.keys())}
109
+ state.next_track_id = len(state.track_ids)
110
+ state.paq_entries = entries
111
+
112
+ console.log.log(f"audio: music tracks loaded {loaded}/{len(MUSIC_TRACKS)} from {paq_path}")
113
+ console.log.flush()
114
+
115
+
116
+ def _normalize_track_key(rel_path: str) -> str:
117
+ name = Path(rel_path.replace("\\", "/")).name
118
+ if name.lower().endswith(".ogg"):
119
+ return name[:-4]
120
+ return name
121
+
122
+
123
+ def _ensure_music_entries(state: MusicState, assets_dir: Path) -> dict[str, bytes] | None:
124
+ if state.paq_entries is not None:
125
+ return state.paq_entries
126
+ paq_path = assets_dir / MUSIC_PAK_NAME
127
+ if not paq_path.exists():
128
+ return None
129
+ entries: dict[str, bytes] = {}
130
+ for name, data in paq.iter_entries(paq_path):
131
+ entries[name.replace("\\", "/")] = data
132
+ state.paq_entries = entries
133
+ return entries
134
+
135
+
136
+ def load_music_track(
137
+ state: MusicState,
138
+ assets_dir: Path,
139
+ rel_path: str,
140
+ *,
141
+ console: ConsoleState | None = None,
142
+ ) -> tuple[str, int] | None:
143
+ normalized = rel_path.replace("\\", "/")
144
+ track_id = state.next_track_id
145
+ state.next_track_id += 1
146
+ if not state.ready or not state.enabled:
147
+ if console is not None:
148
+ console.log.log(f"SFX Tune {track_id} <- '{normalized}' FAILED")
149
+ return None
150
+ key = _normalize_track_key(normalized)
151
+ existing = state.tracks.get(key)
152
+ if existing is not None:
153
+ existing_id = state.track_ids.get(key)
154
+ if existing_id is None:
155
+ state.track_ids[key] = track_id
156
+ existing_id = track_id
157
+ if console is not None:
158
+ console.log.log(f"SFX Tune {existing_id} <- '{normalized}' ok")
159
+ return key, int(existing_id)
160
+ music_stream = None
161
+ file_path = assets_dir / normalized
162
+ if file_path.is_file():
163
+ music_stream = rl.load_music_stream(str(file_path))
164
+ else:
165
+ entries = _ensure_music_entries(state, assets_dir)
166
+ if entries is not None:
167
+ data = entries.get(normalized)
168
+ if data is None:
169
+ data = entries.get(Path(normalized).name)
170
+ if data is not None:
171
+ music_stream = rl.load_music_stream_from_memory(".ogg", data, len(data))
172
+ if music_stream is None:
173
+ if console is not None:
174
+ console.log.log(f"SFX Tune {track_id} <- '{normalized}' FAILED")
175
+ return None
176
+ rl.set_music_volume(music_stream, state.volume)
177
+ state.tracks[key] = music_stream
178
+ state.track_ids[key] = track_id
179
+ if console is not None:
180
+ console.log.log(f"SFX Tune {track_id} <- '{normalized}' ok")
181
+ return key, track_id
182
+
183
+
184
+ def queue_track(state: MusicState, track_key: str) -> None:
185
+ if not state.ready or not state.enabled:
186
+ return
187
+ state.queue.append(track_key)
188
+
189
+
190
+ @dataclass(slots=True)
191
+ class TrackPlayback:
192
+ """Runtime playback state for a loaded music stream."""
193
+
194
+ music: rl.Music
195
+ volume: float
196
+ muted: bool
197
+
198
+
199
+ _MUSIC_MAX_DT = 0.1
200
+ _MUSIC_FADE_IN_PER_SEC = 1.0
201
+ _MUSIC_FADE_OUT_PER_SEC = 0.5
202
+
203
+
204
+ def play_music(state: MusicState, track_name: str) -> None:
205
+ if not state.ready or not state.enabled:
206
+ return
207
+ music = state.tracks.get(track_name)
208
+ if music is None:
209
+ return
210
+
211
+ # Original behavior uses an "exclusive" music channel: starting a new track
212
+ # mutes (fades out) any currently-unmuted music.
213
+ for key, pb in state.playbacks.items():
214
+ if key != track_name:
215
+ pb.muted = True
216
+
217
+ pb = state.playbacks.get(track_name)
218
+ if pb is None:
219
+ pb = TrackPlayback(music=music, volume=0.0, muted=False)
220
+ state.playbacks[track_name] = pb
221
+ else:
222
+ pb.muted = False
223
+
224
+ playing = False
225
+ try:
226
+ playing = bool(rl.is_music_stream_playing(music))
227
+ except Exception:
228
+ playing = False
229
+
230
+ # Mirror `sfx_play_exclusive`: if the track isn't already audible, start it
231
+ # immediately at the target volume. Otherwise, let the fade logic bring it
232
+ # back up (resume behavior).
233
+ if (not playing) or pb.volume <= 0.0:
234
+ pb.volume = state.volume
235
+ try:
236
+ rl.set_music_volume(music, pb.volume)
237
+ except Exception:
238
+ pass
239
+ try:
240
+ rl.play_music_stream(music)
241
+ except Exception:
242
+ pass
243
+
244
+ state.active_track = track_name
245
+
246
+
247
+ def stop_music(state: MusicState) -> None:
248
+ if not state.ready or not state.enabled:
249
+ return
250
+ # Mirror `sfx_mute_all`: mark everything muted and let `update_music` ramp it down.
251
+ for pb in state.playbacks.values():
252
+ pb.muted = True
253
+ state.active_track = None
254
+ state.game_tune_started = False
255
+ state.game_tune_track = None
256
+
257
+
258
+ def trigger_game_tune(state: MusicState, *, rand: Callable[[], int] | None = None) -> str | None:
259
+ """Start a random queued game tune, if it hasn't been triggered yet.
260
+
261
+ Returns the track key if playback started, otherwise None.
262
+ """
263
+ if not state.ready or not state.enabled:
264
+ return None
265
+ if state.game_tune_started:
266
+ return None
267
+ if not state.queue:
268
+ return None
269
+
270
+ if rand is None:
271
+ track_key = random.choice(state.queue)
272
+ else:
273
+ idx = int(rand()) % len(state.queue)
274
+ track_key = state.queue[idx]
275
+
276
+ if track_key not in state.tracks:
277
+ return None
278
+
279
+ play_music(state, track_key)
280
+ state.game_tune_started = True
281
+ state.game_tune_track = track_key
282
+ return track_key
283
+
284
+
285
+ def update_music(state: MusicState, dt: float) -> None:
286
+ if not state.ready or not state.enabled:
287
+ return
288
+ frame_dt = float(dt)
289
+ if frame_dt <= 0.0:
290
+ return
291
+ if frame_dt > _MUSIC_MAX_DT:
292
+ frame_dt = _MUSIC_MAX_DT
293
+
294
+ target_volume = float(state.volume)
295
+ if target_volume <= 0.0:
296
+ # Original behavior: global music volume at 0 stops playback immediately.
297
+ for track_key in list(state.playbacks.keys()):
298
+ pb = state.playbacks.pop(track_key, None)
299
+ if pb is None:
300
+ continue
301
+ try:
302
+ rl.set_music_volume(pb.music, 0.0)
303
+ except Exception:
304
+ pass
305
+ try:
306
+ rl.stop_music_stream(pb.music)
307
+ except Exception:
308
+ pass
309
+ return
310
+
311
+ for track_key in list(state.playbacks.keys()):
312
+ pb = state.playbacks.get(track_key)
313
+ if pb is None:
314
+ continue
315
+ music = pb.music
316
+
317
+ # Keep streams serviced while they play.
318
+ try:
319
+ if rl.is_music_stream_playing(music):
320
+ rl.update_music_stream(music)
321
+ except Exception:
322
+ pass
323
+
324
+ muted = pb.muted or target_volume <= 0.0
325
+ if muted:
326
+ pb.volume -= frame_dt * _MUSIC_FADE_OUT_PER_SEC
327
+ if pb.volume <= 0.0:
328
+ pb.volume = 0.0
329
+ try:
330
+ rl.set_music_volume(music, 0.0)
331
+ except Exception:
332
+ pass
333
+ try:
334
+ rl.stop_music_stream(music)
335
+ except Exception:
336
+ pass
337
+ state.playbacks.pop(track_key, None)
338
+ continue
339
+ try:
340
+ rl.set_music_volume(music, pb.volume)
341
+ except Exception:
342
+ pass
343
+ continue
344
+
345
+ # Unmuted track: ensure it stays playing and ramp toward target volume.
346
+ try:
347
+ if not rl.is_music_stream_playing(music):
348
+ rl.play_music_stream(music)
349
+ except Exception:
350
+ pass
351
+
352
+ if pb.volume > target_volume:
353
+ pb.volume = target_volume
354
+ elif pb.volume < target_volume:
355
+ pb.volume = min(target_volume, pb.volume + frame_dt * _MUSIC_FADE_IN_PER_SEC)
356
+
357
+ try:
358
+ rl.set_music_volume(music, pb.volume)
359
+ except Exception:
360
+ pass
361
+
362
+
363
+ def set_music_volume(state: MusicState, volume: float) -> None:
364
+ volume = float(volume)
365
+ if volume < 0.0:
366
+ volume = 0.0
367
+ if volume > 1.0:
368
+ volume = 1.0
369
+ state.volume = volume
370
+ if not state.ready or not state.enabled:
371
+ return
372
+ # Mirror original: volume decreases take effect immediately; increases are ramped
373
+ # by `update_music`.
374
+ for pb in state.playbacks.values():
375
+ if pb.muted:
376
+ continue
377
+ if pb.volume > state.volume:
378
+ pb.volume = state.volume
379
+ try:
380
+ rl.set_music_volume(pb.music, pb.volume)
381
+ except Exception:
382
+ pass
383
+
384
+
385
+ def shutdown_music(state: MusicState) -> None:
386
+ if not state.ready:
387
+ return
388
+ for pb in list(state.playbacks.values()):
389
+ try:
390
+ rl.stop_music_stream(pb.music)
391
+ except Exception:
392
+ pass
393
+ state.playbacks.clear()
394
+ for music in state.tracks.values():
395
+ try:
396
+ rl.stop_music_stream(music)
397
+ rl.unload_music_stream(music)
398
+ except Exception:
399
+ pass
400
+ state.tracks.clear()
401
+ state.active_track = None
402
+ state.game_tune_started = False
403
+ state.game_tune_track = None
grim/paq.py ADDED
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ """
4
+ PAQ archive format (Crimsonland).
5
+
6
+ File layout:
7
+ - magic: 4 bytes, ASCII "paq\\0"
8
+ - entries: repeated until EOF
9
+ - name: NUL-terminated UTF-8 string (relative path)
10
+ - size: u32 little-endian payload size
11
+ - payload: raw file bytes of length `size`
12
+ """
13
+
14
+ from pathlib import Path
15
+ from typing import Iterable, Iterator
16
+
17
+ from construct import Bytes, Const, CString, GreedyRange, Int32ul, Struct
18
+
19
+ MAGIC = b"paq\x00"
20
+
21
+
22
+ PAQ_ENTRY = Struct(
23
+ "name" / CString("utf8"),
24
+ "size" / Int32ul,
25
+ "payload" / Bytes(lambda ctx: ctx.size),
26
+ )
27
+
28
+ PAQ = Struct(
29
+ "magic" / Const(MAGIC),
30
+ "entries" / GreedyRange(PAQ_ENTRY),
31
+ )
32
+
33
+
34
+ def iter_entries_bytes(data: bytes) -> Iterator[tuple[str, bytes]]:
35
+ parsed = PAQ.parse(data)
36
+ for entry in parsed.entries:
37
+ yield entry.name, entry.payload
38
+
39
+
40
+ def iter_entries(source: str | Path) -> Iterator[tuple[str, bytes]]:
41
+ data = Path(source).read_bytes()
42
+ yield from iter_entries_bytes(data)
43
+
44
+
45
+ def read_paq(source: str | Path) -> list[tuple[str, bytes]]:
46
+ return list(iter_entries(source))
47
+
48
+
49
+ def decode_bytes(data: bytes) -> list[tuple[str, bytes]]:
50
+ return list(iter_entries_bytes(data))
51
+
52
+
53
+ def build_entries(entries: Iterable[tuple[str, bytes]]) -> bytes:
54
+ built_entries = []
55
+ for name, data in entries:
56
+ if isinstance(name, Path):
57
+ name = str(name)
58
+ if isinstance(data, memoryview):
59
+ data = data.tobytes()
60
+ built_entries.append(
61
+ {
62
+ "name": str(name),
63
+ "size": len(data),
64
+ "payload": bytes(data),
65
+ }
66
+ )
67
+ return PAQ.build({"magic": MAGIC, "entries": built_entries})
68
+
69
+
70
+ def write_paq(dest: str | Path, entries: Iterable[tuple[str, bytes]]) -> None:
71
+ data = build_entries(entries)
72
+ Path(dest).write_bytes(data)
73
+
74
+
75
+ def encode_bytes(entries: Iterable[tuple[str, bytes]]) -> bytes:
76
+ return build_entries(entries)
grim/rand.py ADDED
@@ -0,0 +1,37 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ CRT_RAND_MULT = 214013
6
+ CRT_RAND_INC = 2531011
7
+
8
+
9
+ class CrtRand:
10
+ """MSVCRT-compatible `rand()` LCG used by the original game.
11
+
12
+ Matches:
13
+ seed = seed * 214013 + 2531011
14
+ return (seed >> 16) & 0x7fff
15
+ """
16
+
17
+ __slots__ = ("_state",)
18
+
19
+ def __init__(self, seed: int | None = None) -> None:
20
+ if seed is None:
21
+ seed = int.from_bytes(os.urandom(4), "little")
22
+ self._state = seed & 0xFFFFFFFF
23
+
24
+ @property
25
+ def state(self) -> int:
26
+ return self._state
27
+
28
+ def srand(self, seed: int) -> None:
29
+ self._state = seed & 0xFFFFFFFF
30
+
31
+ def rand(self) -> int:
32
+ self._state = (self._state * CRT_RAND_MULT + CRT_RAND_INC) & 0xFFFFFFFF
33
+ return (self._state >> 16) & 0x7FFF
34
+
35
+
36
+ class Crand(CrtRand):
37
+ """Backward-compatible name for the MSVCRT LCG."""