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
@@ -0,0 +1 @@
1
+ from __future__ import annotations
@@ -0,0 +1,385 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ import datetime as dt
5
+ from pathlib import Path
6
+ import struct
7
+
8
+ from grim.config import CrimsonConfig
9
+
10
+
11
+ RECORD_SIZE = 0x48
12
+ RECORD_WIRE_SIZE = RECORD_SIZE + 4 # record + checksum
13
+ TABLE_MAX = 100
14
+
15
+ NAME_SIZE = 0x20
16
+ NAME_MAX_EDIT = 0x14 # game_over_screen_update sets ui_text_input maxlen=0x14
17
+
18
+
19
+ def _clamp_u32(value: int) -> int:
20
+ return int(value) & 0xFFFFFFFF
21
+
22
+
23
+ def _score_checksum(data: bytes) -> int:
24
+ if len(data) != RECORD_SIZE:
25
+ raise ValueError(f"expected {RECORD_SIZE:#x} bytes, got {len(data):#x}")
26
+ checksum = 0
27
+ for idx, b in enumerate(data):
28
+ checksum = _clamp_u32(checksum + (idx + 3) * int(b) * 7)
29
+ return checksum
30
+
31
+
32
+ def _encode_byte(value: int, idx: int) -> int:
33
+ # highscore_write_record: b += ((idx * 5 + 1) * idx + 6)
34
+ return (int(value) + (idx * 5 + 1) * idx + 6) & 0xFF
35
+
36
+
37
+ def _decode_byte(value: int, idx: int) -> int:
38
+ # highscore_read_record: b += (-6 - ((idx * 5 + 1) * idx))
39
+ return (int(value) - ((idx * 5 + 1) * idx + 6)) & 0xFF
40
+
41
+
42
+ def highscore_date_checksum(year: int, month: int, day: int) -> int:
43
+ """Port of `highscore_date_checksum` (0x0043a950)."""
44
+ i_var1 = (0x0E - int(month)) // 0x0C
45
+ i_var2 = (int(year) - i_var1) + 0x12C0
46
+ i_var1 = (
47
+ ((i_var2 + ((i_var2 >> 31) & 3)) >> 2)
48
+ - 0x7D2D
49
+ + int(day)
50
+ + ((i_var2 // 400 + (((int(month) + i_var1 * 0x0C) * 0x99 - 0x1C9) // 5 + i_var2 * 0x16D)) - i_var2 // 100)
51
+ )
52
+ i_var2 = ((((i_var1 - i_var1 % 7) + 0x7BFD) % 0x23AB1) % 0x8EAC) % 0x5B5
53
+ i_var1 = i_var2 // 0x5B4
54
+ return ((i_var2 - i_var1) % 0x16D + i_var1) // 7 + 1
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class HighScoreRecord:
59
+ data: bytearray
60
+
61
+ @classmethod
62
+ def blank(cls) -> HighScoreRecord:
63
+ data = bytearray(RECORD_SIZE)
64
+ data[0x46] = 0x7C
65
+ data[0x47] = 0xFF
66
+ return cls(data=data)
67
+
68
+ @classmethod
69
+ def from_bytes(cls, data: bytes) -> HighScoreRecord:
70
+ if len(data) != RECORD_SIZE:
71
+ raise ValueError(f"expected {RECORD_SIZE:#x} bytes, got {len(data):#x}")
72
+ return cls(data=bytearray(data))
73
+
74
+ def copy(self) -> HighScoreRecord:
75
+ return HighScoreRecord(data=bytearray(self.data))
76
+
77
+ def name(self) -> str:
78
+ raw = bytes(self.data[:NAME_SIZE])
79
+ return raw.split(b"\x00", 1)[0].decode("latin-1", errors="ignore")
80
+
81
+ def set_name(self, value: str) -> None:
82
+ encoded = value.encode("latin-1", errors="ignore")[: NAME_SIZE - 1]
83
+ self.data[:NAME_SIZE] = b"\x00" * NAME_SIZE
84
+ self.data[: len(encoded)] = encoded
85
+ self.data[min(len(encoded), NAME_SIZE - 1)] = 0
86
+
87
+ def trim_trailing_spaces(self) -> None:
88
+ # highscore_save_record: strips trailing spaces (0x20) in-place before saving.
89
+ raw = self.data[:NAME_SIZE]
90
+ end = raw.find(0)
91
+ if end < 0:
92
+ end = NAME_SIZE
93
+ i = end - 1
94
+ while i > 0 and raw[i] == 0x20:
95
+ raw[i] = 0
96
+ i -= 1
97
+
98
+ @property
99
+ def survival_elapsed_ms(self) -> int:
100
+ return int(struct.unpack_from("<I", self.data, 0x20)[0])
101
+
102
+ @survival_elapsed_ms.setter
103
+ def survival_elapsed_ms(self, value: int) -> None:
104
+ struct.pack_into("<I", self.data, 0x20, int(value) & 0xFFFFFFFF)
105
+
106
+ @property
107
+ def score_xp(self) -> int:
108
+ return int(struct.unpack_from("<I", self.data, 0x24)[0])
109
+
110
+ @score_xp.setter
111
+ def score_xp(self, value: int) -> None:
112
+ struct.pack_into("<I", self.data, 0x24, int(value) & 0xFFFFFFFF)
113
+
114
+ @property
115
+ def game_mode_id(self) -> int:
116
+ return int(self.data[0x28])
117
+
118
+ @game_mode_id.setter
119
+ def game_mode_id(self, value: int) -> None:
120
+ self.data[0x28] = int(value) & 0xFF
121
+
122
+ @property
123
+ def quest_stage_major(self) -> int:
124
+ return int(self.data[0x29])
125
+
126
+ @quest_stage_major.setter
127
+ def quest_stage_major(self, value: int) -> None:
128
+ self.data[0x29] = int(value) & 0xFF
129
+
130
+ @property
131
+ def quest_stage_minor(self) -> int:
132
+ return int(self.data[0x2A])
133
+
134
+ @quest_stage_minor.setter
135
+ def quest_stage_minor(self, value: int) -> None:
136
+ self.data[0x2A] = int(value) & 0xFF
137
+
138
+ @property
139
+ def most_used_weapon_id(self) -> int:
140
+ return int(self.data[0x2B])
141
+
142
+ @most_used_weapon_id.setter
143
+ def most_used_weapon_id(self, value: int) -> None:
144
+ self.data[0x2B] = int(value) & 0xFF
145
+
146
+ @property
147
+ def shots_fired(self) -> int:
148
+ return int(struct.unpack_from("<I", self.data, 0x2C)[0])
149
+
150
+ @shots_fired.setter
151
+ def shots_fired(self, value: int) -> None:
152
+ struct.pack_into("<I", self.data, 0x2C, int(value) & 0xFFFFFFFF)
153
+
154
+ @property
155
+ def shots_hit(self) -> int:
156
+ return int(struct.unpack_from("<I", self.data, 0x30)[0])
157
+
158
+ @shots_hit.setter
159
+ def shots_hit(self, value: int) -> None:
160
+ struct.pack_into("<I", self.data, 0x30, int(value) & 0xFFFFFFFF)
161
+
162
+ @property
163
+ def creature_kill_count(self) -> int:
164
+ return int(struct.unpack_from("<I", self.data, 0x34)[0])
165
+
166
+ @creature_kill_count.setter
167
+ def creature_kill_count(self, value: int) -> None:
168
+ struct.pack_into("<I", self.data, 0x34, int(value) & 0xFFFFFFFF)
169
+
170
+ @property
171
+ def reserved0(self) -> int:
172
+ return int(struct.unpack_from("<I", self.data, 0x38)[0])
173
+
174
+ @reserved0.setter
175
+ def reserved0(self, value: int) -> None:
176
+ struct.pack_into("<I", self.data, 0x38, int(value) & 0xFFFFFFFF)
177
+
178
+ @property
179
+ def day(self) -> int:
180
+ return int(self.data[0x40])
181
+
182
+ @property
183
+ def month(self) -> int:
184
+ return int(self.data[0x42])
185
+
186
+ @property
187
+ def year_offset(self) -> int:
188
+ return int(self.data[0x43])
189
+
190
+ @property
191
+ def flags(self) -> int:
192
+ return int(self.data[0x44])
193
+
194
+ @flags.setter
195
+ def flags(self, value: int) -> None:
196
+ self.data[0x44] = int(value) & 0xFF
197
+
198
+ @property
199
+ def full_version_marker(self) -> int:
200
+ return int(self.data[0x45])
201
+
202
+ @full_version_marker.setter
203
+ def full_version_marker(self, value: int) -> None:
204
+ self.data[0x45] = int(value) & 0xFF
205
+
206
+ def ensure_date_fields(self, now: dt.date | None = None) -> None:
207
+ if int(self.data[0x40]) != 0:
208
+ return
209
+ if now is None:
210
+ now = dt.date.today()
211
+ self.data[0x40] = int(now.day) & 0xFF
212
+ self.data[0x42] = int(now.month) & 0xFF
213
+ self.data[0x43] = int(now.year - 2000) & 0xFF
214
+ self.data[0x41] = int(highscore_date_checksum(now.year, now.month, now.day)) & 0xFF
215
+
216
+
217
+ def scores_dir_for_base_dir(base_dir: Path) -> Path:
218
+ # Original uses CreateDirectoryA("scores5") relative to cwd.
219
+ return base_dir / "scores5"
220
+
221
+
222
+ def scores_path_for_mode(
223
+ base_dir: Path,
224
+ game_mode_id: int,
225
+ *,
226
+ hardcore: bool = False,
227
+ quest_stage_major: int = 0,
228
+ quest_stage_minor: int = 0,
229
+ ) -> Path:
230
+ root = scores_dir_for_base_dir(base_dir)
231
+ mode = int(game_mode_id)
232
+ if mode == 1:
233
+ return root / "survival.hi"
234
+ if mode == 2:
235
+ return root / "rush.hi"
236
+ if mode == 4:
237
+ return root / "typo.hi"
238
+ if mode == 3:
239
+ # Native `highscore_build_path` uses `questhc*.hi` when hardcore is OFF,
240
+ # and `quest*.hi` when hardcore is ON.
241
+ prefix = "quest" if hardcore else "questhc"
242
+ return root / f"{prefix}{int(quest_stage_major)}_{int(quest_stage_minor)}.hi"
243
+ return root / "unknown.hi"
244
+
245
+
246
+ def scores_path_for_config(base_dir: Path, config: CrimsonConfig, *, quest_stage_major: int = 0, quest_stage_minor: int = 0) -> Path:
247
+ mode = int(config.data.get("game_mode", 1))
248
+ root = scores_dir_for_base_dir(base_dir)
249
+ if mode == 1:
250
+ return root / "survival.hi"
251
+ if mode == 2:
252
+ return root / "rush.hi"
253
+ if mode == 4:
254
+ return root / "typo.hi"
255
+ if mode == 3:
256
+ hardcore = bool(int(config.data.get("hardcore_flag", 0) or 0))
257
+ if int(quest_stage_major) == 0 and int(quest_stage_minor) == 0:
258
+ major = int(config.data.get("quest_stage_major", 0) or 0)
259
+ minor = int(config.data.get("quest_stage_minor", 0) or 0)
260
+ if major == 0 and minor == 0:
261
+ level = config.data.get("quest_level")
262
+ if isinstance(level, str):
263
+ try:
264
+ major_text, minor_text = level.split(".", 1)
265
+ major = int(major_text)
266
+ minor = int(minor_text)
267
+ except Exception:
268
+ major = 0
269
+ minor = 0
270
+ quest_stage_major = major
271
+ quest_stage_minor = minor
272
+ # Native `highscore_build_path` uses `questhc*.hi` when hardcore is OFF,
273
+ # and `quest*.hi` when hardcore is ON.
274
+ prefix = "quest" if hardcore else "questhc"
275
+ return root / f"{prefix}{int(quest_stage_major)}_{int(quest_stage_minor)}.hi"
276
+ return root / "unknown.hi"
277
+
278
+
279
+ def decode_record_payload(encoded: bytes) -> bytes:
280
+ if len(encoded) != RECORD_SIZE:
281
+ raise ValueError(f"expected {RECORD_SIZE:#x} bytes, got {len(encoded):#x}")
282
+ out = bytearray(encoded)
283
+ for idx in range(RECORD_SIZE):
284
+ out[idx] = _decode_byte(out[idx], idx)
285
+ return bytes(out)
286
+
287
+
288
+ def encode_record_payload(decoded: bytes) -> bytes:
289
+ if len(decoded) != RECORD_SIZE:
290
+ raise ValueError(f"expected {RECORD_SIZE:#x} bytes, got {len(decoded):#x}")
291
+ out = bytearray(decoded)
292
+ for idx in range(RECORD_SIZE):
293
+ out[idx] = _encode_byte(out[idx], idx)
294
+ return bytes(out)
295
+
296
+
297
+ def read_highscore_records(path: Path) -> list[HighScoreRecord]:
298
+ if not path.is_file():
299
+ return []
300
+ records: list[HighScoreRecord] = []
301
+ with path.open("rb") as fp:
302
+ while True:
303
+ blob = fp.read(RECORD_WIRE_SIZE)
304
+ if not blob:
305
+ break
306
+ if len(blob) != RECORD_WIRE_SIZE:
307
+ break
308
+ payload = blob[:RECORD_SIZE]
309
+ stored_checksum = int(struct.unpack_from("<I", blob, RECORD_SIZE)[0])
310
+ decoded = decode_record_payload(payload)
311
+ computed = _score_checksum(decoded)
312
+ if computed != stored_checksum:
313
+ continue
314
+ records.append(HighScoreRecord.from_bytes(decoded))
315
+ return records
316
+
317
+
318
+ def write_highscore_records(path: Path, records: list[HighScoreRecord]) -> None:
319
+ path.parent.mkdir(parents=True, exist_ok=True)
320
+ with path.open("wb") as fp:
321
+ for record in records:
322
+ record = record.copy()
323
+ record.trim_trailing_spaces()
324
+ record.ensure_date_fields()
325
+ encoded = encode_record_payload(bytes(record.data))
326
+ checksum = _score_checksum(bytes(record.data))
327
+ fp.write(encoded)
328
+ fp.write(struct.pack("<I", checksum))
329
+
330
+
331
+ def read_highscore_table(path: Path, *, game_mode_id: int) -> list[HighScoreRecord]:
332
+ records = read_highscore_records(path)
333
+ records = [r for r in records if int(r.game_mode_id) == int(game_mode_id)]
334
+ return sort_highscores(records, game_mode_id=game_mode_id)[:TABLE_MAX]
335
+
336
+
337
+ def sort_highscores(records: list[HighScoreRecord], *, game_mode_id: int) -> list[HighScoreRecord]:
338
+ mode = int(game_mode_id)
339
+ if mode == 2:
340
+ return sorted(records, key=lambda r: int(r.survival_elapsed_ms), reverse=True)
341
+ if mode == 3:
342
+ def _quest_key(r: HighScoreRecord) -> tuple[int, int]:
343
+ value = int(r.survival_elapsed_ms)
344
+ if value == 0:
345
+ return (1, 0)
346
+ return (0, value)
347
+ return sorted(records, key=_quest_key)
348
+ return sorted(records, key=lambda r: int(r.score_xp), reverse=True)
349
+
350
+
351
+ def rank_index(records_sorted: list[HighScoreRecord], record: HighScoreRecord) -> int:
352
+ mode = int(record.game_mode_id)
353
+ if mode == 2:
354
+ score = int(record.survival_elapsed_ms)
355
+ for idx, entry in enumerate(records_sorted):
356
+ if score > int(entry.survival_elapsed_ms):
357
+ return idx
358
+ return len(records_sorted)
359
+ if mode == 3:
360
+ score = int(record.survival_elapsed_ms)
361
+ for idx, entry in enumerate(records_sorted):
362
+ other = int(entry.survival_elapsed_ms)
363
+ if other == 0:
364
+ return idx
365
+ if score < other:
366
+ return idx
367
+ return len(records_sorted)
368
+ score = int(record.score_xp)
369
+ for idx, entry in enumerate(records_sorted):
370
+ if score > int(entry.score_xp):
371
+ return idx
372
+ return len(records_sorted)
373
+
374
+
375
+ def upsert_highscore_record(path: Path, record: HighScoreRecord) -> tuple[list[HighScoreRecord], int]:
376
+ """Save `record` into the mode table, returning (sorted_records, rank_index)."""
377
+ records_sorted = read_highscore_table(path, game_mode_id=record.game_mode_id)
378
+ idx = rank_index(records_sorted, record)
379
+ if idx >= TABLE_MAX:
380
+ return records_sorted, idx
381
+ updated = list(records_sorted)
382
+ updated.insert(idx, record.copy())
383
+ updated = updated[:TABLE_MAX]
384
+ write_highscore_records(path, updated)
385
+ return updated, idx
@@ -0,0 +1,245 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ from construct import Array, Bytes, Int16ul, Int32ul, Struct
7
+
8
+ GAME_CFG_NAME = "game.cfg"
9
+
10
+ BLOB_SIZE = 0x268
11
+ FILE_SIZE = BLOB_SIZE + 4
12
+
13
+ WEAPON_USAGE_COUNT = 53
14
+
15
+ # Quest play count length inferred from known trailing fields in the blob (0xD8..0x244).
16
+ QUEST_PLAY_COUNT = 91
17
+
18
+ MODE_COUNT_ORDER = (
19
+ ("survival", "mode_play_survival"),
20
+ ("rush", "mode_play_rush"),
21
+ ("typo", "mode_play_typo"),
22
+ ("other", "mode_play_other"),
23
+ )
24
+
25
+ UNKNOWN_TAIL_SIZE = 0x10
26
+
27
+ GAME_STATUS_STRUCT = Struct(
28
+ "quest_unlock_index" / Int16ul,
29
+ "quest_unlock_index_full" / Int16ul,
30
+ "weapon_usage_counts" / Array(WEAPON_USAGE_COUNT, Int32ul),
31
+ "quest_play_counts" / Array(QUEST_PLAY_COUNT, Int32ul),
32
+ "mode_play_survival" / Int32ul,
33
+ "mode_play_rush" / Int32ul,
34
+ "mode_play_typo" / Int32ul,
35
+ "mode_play_other" / Int32ul,
36
+ "game_sequence_id" / Int32ul,
37
+ "unknown_tail" / Bytes(UNKNOWN_TAIL_SIZE),
38
+ )
39
+
40
+ GAME_CFG_STRUCT = Struct(
41
+ "encoded" / Bytes(BLOB_SIZE),
42
+ "checksum" / Int32ul,
43
+ )
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class StatusBlob:
48
+ decoded: bytes
49
+ checksum: int
50
+ checksum_expected: int
51
+
52
+ @property
53
+ def checksum_valid(self) -> bool:
54
+ return (self.checksum & 0xFFFFFFFF) == (self.checksum_expected & 0xFFFFFFFF)
55
+
56
+
57
+ @dataclass(slots=True)
58
+ class GameStatus:
59
+ path: Path
60
+ data: dict
61
+ dirty: bool = False
62
+
63
+ @property
64
+ def quest_unlock_index(self) -> int:
65
+ return int(self.data["quest_unlock_index"])
66
+
67
+ @quest_unlock_index.setter
68
+ def quest_unlock_index(self, value: int) -> None:
69
+ self.data["quest_unlock_index"] = int(value) & 0xFFFF
70
+ self.dirty = True
71
+
72
+ @property
73
+ def quest_unlock_index_full(self) -> int:
74
+ return int(self.data["quest_unlock_index_full"])
75
+
76
+ @quest_unlock_index_full.setter
77
+ def quest_unlock_index_full(self, value: int) -> None:
78
+ self.data["quest_unlock_index_full"] = int(value) & 0xFFFF
79
+ self.dirty = True
80
+
81
+ @property
82
+ def game_sequence_id(self) -> int:
83
+ return int(self.data["game_sequence_id"])
84
+
85
+ @game_sequence_id.setter
86
+ def game_sequence_id(self, value: int) -> None:
87
+ self.data["game_sequence_id"] = int(value) & 0xFFFFFFFF
88
+ self.dirty = True
89
+
90
+ def mode_play_count(self, name: str) -> int:
91
+ for mode_name, field in MODE_COUNT_ORDER:
92
+ if mode_name == name:
93
+ return int(self.data[field])
94
+ raise KeyError(f"unknown mode: {name}")
95
+
96
+ def increment_mode_play_count(self, name: str, delta: int = 1) -> int:
97
+ for mode_name, field in MODE_COUNT_ORDER:
98
+ if mode_name == name:
99
+ value = (int(self.data[field]) + int(delta)) & 0xFFFFFFFF
100
+ self.data[field] = value
101
+ self.dirty = True
102
+ return value
103
+ raise KeyError(f"unknown mode: {name}")
104
+
105
+ def weapon_usage_count(self, weapon_id: int) -> int:
106
+ weapon_id = int(weapon_id)
107
+ if not (0 <= weapon_id < WEAPON_USAGE_COUNT):
108
+ raise IndexError(f"weapon_id out of range: {weapon_id}")
109
+ return int(self.data["weapon_usage_counts"][weapon_id])
110
+
111
+ def increment_weapon_usage(self, weapon_id: int, delta: int = 1) -> int:
112
+ weapon_id = int(weapon_id)
113
+ if not (0 <= weapon_id < WEAPON_USAGE_COUNT):
114
+ raise IndexError(f"weapon_id out of range: {weapon_id}")
115
+ counts = self.data["weapon_usage_counts"]
116
+ value = (int(counts[weapon_id]) + int(delta)) & 0xFFFFFFFF
117
+ counts[weapon_id] = value
118
+ self.dirty = True
119
+ return value
120
+
121
+ def quest_play_count(self, index: int) -> int:
122
+ index = int(index)
123
+ if not (0 <= index < QUEST_PLAY_COUNT):
124
+ raise IndexError(f"quest index out of range: {index}")
125
+ return int(self.data["quest_play_counts"][index])
126
+
127
+ def increment_quest_play_count(self, index: int, delta: int = 1) -> int:
128
+ index = int(index)
129
+ if not (0 <= index < QUEST_PLAY_COUNT):
130
+ raise IndexError(f"quest index out of range: {index}")
131
+ counts = self.data["quest_play_counts"]
132
+ value = (int(counts[index]) + int(delta)) & 0xFFFFFFFF
133
+ counts[index] = value
134
+ self.dirty = True
135
+ return value
136
+
137
+ def unknown_tail(self) -> bytes:
138
+ return bytes(self.data["unknown_tail"])
139
+
140
+ def save(self) -> None:
141
+ self.path.parent.mkdir(parents=True, exist_ok=True)
142
+ decoded = build_status_blob(self.data)
143
+ save_status(self.path, decoded)
144
+ self.dirty = False
145
+
146
+ def save_if_dirty(self) -> None:
147
+ if self.dirty:
148
+ self.save()
149
+
150
+
151
+ def to_s8(value: int) -> int:
152
+ value &= 0xFF
153
+ return value - 0x100 if value & 0x80 else value
154
+
155
+
156
+ def index_poly(idx: int) -> int:
157
+ i = to_s8(idx)
158
+ return ((i * 7 + 0x0F) * i + 0x03) * i
159
+
160
+
161
+ def decode_blob(encoded: bytes) -> bytes:
162
+ if len(encoded) != BLOB_SIZE:
163
+ raise ValueError(f"decoded blob must be {BLOB_SIZE:#x} bytes, got {len(encoded):#x}")
164
+ decoded = bytearray(encoded)
165
+ for i in range(BLOB_SIZE):
166
+ decoded[i] = (decoded[i] - 0x6F - index_poly(i)) & 0xFF
167
+ return bytes(decoded)
168
+
169
+
170
+ def encode_blob(decoded: bytes) -> bytes:
171
+ if len(decoded) != BLOB_SIZE:
172
+ raise ValueError(f"decoded blob must be {BLOB_SIZE:#x} bytes, got {len(decoded):#x}")
173
+ encoded = bytearray(decoded)
174
+ for i in range(BLOB_SIZE):
175
+ encoded[i] = (encoded[i] + 0x6F + index_poly(i)) & 0xFF
176
+ return bytes(encoded)
177
+
178
+
179
+ def compute_checksum(decoded: bytes) -> int:
180
+ acc = 0
181
+ u = 0
182
+ for i, b in enumerate(decoded):
183
+ c = to_s8(b)
184
+ i_var5 = (c * 7 + i) * c + u
185
+ acc = (acc + 0x0D + i_var5) & 0xFFFFFFFF
186
+ u += 0x6F
187
+ return acc
188
+
189
+
190
+ def load_status(path: Path) -> StatusBlob:
191
+ raw = path.read_bytes()
192
+ if len(raw) != FILE_SIZE:
193
+ raise ValueError(f"expected {FILE_SIZE:#x} bytes, got {len(raw):#x}")
194
+ parsed = GAME_CFG_STRUCT.parse(raw)
195
+ encoded = bytes(parsed["encoded"])
196
+ stored_checksum = int(parsed["checksum"])
197
+ decoded = decode_blob(encoded)
198
+ computed = compute_checksum(decoded)
199
+ return StatusBlob(decoded=decoded, checksum=stored_checksum, checksum_expected=computed)
200
+
201
+
202
+ def save_status(path: Path, decoded: bytes) -> None:
203
+ checksum = compute_checksum(decoded)
204
+ encoded = encode_blob(decoded)
205
+ path.write_bytes(GAME_CFG_STRUCT.build({"encoded": encoded, "checksum": checksum}))
206
+
207
+
208
+ def parse_status_blob(decoded: bytes) -> dict:
209
+ if len(decoded) != BLOB_SIZE:
210
+ raise ValueError(f"expected decoded blob of {BLOB_SIZE:#x} bytes, got {len(decoded):#x}")
211
+ return GAME_STATUS_STRUCT.parse(decoded)
212
+
213
+
214
+ def build_status_blob(data: dict) -> bytes:
215
+ decoded = GAME_STATUS_STRUCT.build(data)
216
+ if len(decoded) != BLOB_SIZE:
217
+ raise ValueError(f"expected decoded blob of {BLOB_SIZE:#x} bytes, got {len(decoded):#x}")
218
+ return decoded
219
+
220
+
221
+ def default_status_data() -> dict:
222
+ return parse_status_blob(bytes(BLOB_SIZE))
223
+
224
+
225
+ def default_status_blob() -> bytes:
226
+ return bytes(BLOB_SIZE)
227
+
228
+
229
+ def ensure_game_status(base_dir: Path) -> GameStatus:
230
+ path = base_dir / GAME_CFG_NAME
231
+ if path.exists():
232
+ try:
233
+ blob = load_status(path)
234
+ if not blob.checksum_valid:
235
+ raise ValueError("checksum mismatch")
236
+ data = parse_status_blob(blob.decoded)
237
+ except Exception:
238
+ data = default_status_data()
239
+ decoded = build_status_blob(data)
240
+ save_status(path, decoded)
241
+ return GameStatus(path=path, data=data, dirty=False)
242
+ data = default_status_data()
243
+ decoded = build_status_blob(data)
244
+ save_status(path, decoded)
245
+ return GameStatus(path=path, data=data, dirty=False)
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ """Player damage intake helpers.
4
+
5
+ This is a minimal, rewrite-focused port of `player_take_damage` (0x00425e50).
6
+ See: `docs/crimsonland-exe/player-damage.md`.
7
+ """
8
+
9
+ from typing import Callable
10
+
11
+ from .gameplay import GameplayState, PlayerState, perk_active
12
+ from .perks import PerkId
13
+
14
+ __all__ = ["player_take_damage"]
15
+
16
+
17
+ def player_take_damage(
18
+ state: GameplayState,
19
+ player: PlayerState,
20
+ damage: float,
21
+ *,
22
+ dt: float | None = None,
23
+ rand: Callable[[], int] | None = None,
24
+ ) -> float:
25
+ """Apply damage to a player, returning the actual damage applied.
26
+
27
+ Notes:
28
+ - This models only the must-have gates used by creature contact damage.
29
+ - Low-health warning timers are not yet ported.
30
+ """
31
+
32
+ dmg = float(damage)
33
+ if dmg <= 0.0:
34
+ return 0.0
35
+
36
+ # 1) Death Clock immunity.
37
+ if perk_active(player, PerkId.DEATH_CLOCK):
38
+ return 0.0
39
+
40
+ # 2) Tough Reloader mitigation while reloading.
41
+ if perk_active(player, PerkId.TOUGH_RELOADER) and bool(player.reload_active):
42
+ dmg *= 0.5
43
+
44
+ # 3) Shield immunity.
45
+ if float(player.shield_timer) > 0.0:
46
+ return 0.0
47
+
48
+ # Damage scaling perks.
49
+ if perk_active(player, PerkId.THICK_SKINNED):
50
+ dmg *= 2.0 / 3.0
51
+
52
+ rng = rand or state.rng.rand
53
+ if perk_active(player, PerkId.NINJA):
54
+ if (rng() % 3) == 0:
55
+ return 0.0
56
+ elif perk_active(player, PerkId.DODGER):
57
+ if (rng() % 5) == 0:
58
+ return 0.0
59
+
60
+ health_before = float(player.health)
61
+
62
+ if perk_active(player, PerkId.HIGHLANDER):
63
+ if (rng() % 10) == 0:
64
+ player.health = 0.0
65
+ else:
66
+ player.health -= dmg
67
+ if player.health < 0.0 and dt is not None and float(dt) > 0.0:
68
+ player.death_timer -= float(dt) * 28.0
69
+
70
+ if not perk_active(player, PerkId.UNSTOPPABLE):
71
+ # player_take_damage @ 0x00425e50: on-hit camera/spread disruption.
72
+ player.heading += float((rng() % 100) - 50) * 0.04
73
+ player.spread_heat = min(0.48, float(player.spread_heat) + dmg * 0.01)
74
+
75
+ if player.health <= 20.0 and (rng() & 7) == 3:
76
+ player.low_health_timer = 0.0
77
+ return max(0.0, health_before - float(player.health))