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.
- crimson/__init__.py +24 -0
- crimson/assets_fetch.py +60 -0
- crimson/atlas.py +92 -0
- crimson/audio_router.py +155 -0
- crimson/bonuses.py +167 -0
- crimson/camera.py +75 -0
- crimson/cli.py +380 -0
- crimson/creatures/__init__.py +8 -0
- crimson/creatures/ai.py +186 -0
- crimson/creatures/anim.py +173 -0
- crimson/creatures/damage.py +103 -0
- crimson/creatures/runtime.py +1019 -0
- crimson/creatures/spawn.py +2871 -0
- crimson/debug.py +7 -0
- crimson/demo.py +1360 -0
- crimson/demo_trial.py +140 -0
- crimson/effects.py +1086 -0
- crimson/effects_atlas.py +73 -0
- crimson/frontend/__init__.py +1 -0
- crimson/frontend/assets.py +43 -0
- crimson/frontend/boot.py +424 -0
- crimson/frontend/menu.py +700 -0
- crimson/frontend/panels/__init__.py +1 -0
- crimson/frontend/panels/base.py +410 -0
- crimson/frontend/panels/controls.py +132 -0
- crimson/frontend/panels/mods.py +128 -0
- crimson/frontend/panels/options.py +409 -0
- crimson/frontend/panels/play_game.py +627 -0
- crimson/frontend/panels/stats.py +351 -0
- crimson/frontend/transitions.py +31 -0
- crimson/game.py +2533 -0
- crimson/game_modes.py +15 -0
- crimson/game_world.py +652 -0
- crimson/gameplay.py +2467 -0
- crimson/input_codes.py +176 -0
- crimson/modes/__init__.py +1 -0
- crimson/modes/base_gameplay_mode.py +219 -0
- crimson/modes/quest_mode.py +502 -0
- crimson/modes/rush_mode.py +300 -0
- crimson/modes/survival_mode.py +792 -0
- crimson/modes/tutorial_mode.py +648 -0
- crimson/modes/typo_mode.py +472 -0
- crimson/paths.py +23 -0
- crimson/perks.py +828 -0
- crimson/persistence/__init__.py +1 -0
- crimson/persistence/highscores.py +385 -0
- crimson/persistence/save_status.py +245 -0
- crimson/player_damage.py +77 -0
- crimson/projectiles.py +1133 -0
- crimson/quests/__init__.py +18 -0
- crimson/quests/helpers.py +147 -0
- crimson/quests/registry.py +49 -0
- crimson/quests/results.py +164 -0
- crimson/quests/runtime.py +91 -0
- crimson/quests/tier1.py +620 -0
- crimson/quests/tier2.py +652 -0
- crimson/quests/tier3.py +579 -0
- crimson/quests/tier4.py +721 -0
- crimson/quests/tier5.py +886 -0
- crimson/quests/timeline.py +115 -0
- crimson/quests/types.py +70 -0
- crimson/render/__init__.py +1 -0
- crimson/render/terrain_fx.py +88 -0
- crimson/render/world_renderer.py +1941 -0
- crimson/sim/__init__.py +1 -0
- crimson/sim/world_defs.py +67 -0
- crimson/sim/world_state.py +422 -0
- crimson/terrain_assets.py +19 -0
- crimson/tutorial/__init__.py +12 -0
- crimson/tutorial/timeline.py +291 -0
- crimson/typo/__init__.py +2 -0
- crimson/typo/names.py +233 -0
- crimson/typo/player.py +43 -0
- crimson/typo/spawns.py +73 -0
- crimson/typo/typing.py +52 -0
- crimson/ui/__init__.py +3 -0
- crimson/ui/cursor.py +95 -0
- crimson/ui/demo_trial_overlay.py +235 -0
- crimson/ui/game_over.py +660 -0
- crimson/ui/hud.py +601 -0
- crimson/ui/perk_menu.py +388 -0
- crimson/views/__init__.py +40 -0
- crimson/views/aim_debug.py +276 -0
- crimson/views/animations.py +274 -0
- crimson/views/arsenal_debug.py +404 -0
- crimson/views/audio_bootstrap.py +47 -0
- crimson/views/bonuses.py +201 -0
- crimson/views/camera_debug.py +359 -0
- crimson/views/camera_shake.py +229 -0
- crimson/views/corpse_stamp_debug.py +324 -0
- crimson/views/decals_debug.py +739 -0
- crimson/views/empty.py +19 -0
- crimson/views/fonts.py +114 -0
- crimson/views/game_over.py +117 -0
- crimson/views/ground.py +259 -0
- crimson/views/lighting_debug.py +1166 -0
- crimson/views/particles.py +293 -0
- crimson/views/perk_menu_debug.py +430 -0
- crimson/views/perks.py +398 -0
- crimson/views/player.py +434 -0
- crimson/views/player_sprite_debug.py +314 -0
- crimson/views/projectile_fx.py +609 -0
- crimson/views/projectile_render_debug.py +393 -0
- crimson/views/projectiles.py +221 -0
- crimson/views/quest_title_overlay.py +108 -0
- crimson/views/registry.py +34 -0
- crimson/views/rush.py +16 -0
- crimson/views/small_font_debug.py +204 -0
- crimson/views/spawn_plan.py +363 -0
- crimson/views/sprites.py +214 -0
- crimson/views/survival.py +15 -0
- crimson/views/terrain.py +132 -0
- crimson/views/ui.py +123 -0
- crimson/views/wicons.py +166 -0
- crimson/weapon_sfx.py +63 -0
- crimson/weapons.py +860 -0
- crimsonland-0.1.0.dev5.dist-info/METADATA +9 -0
- crimsonland-0.1.0.dev5.dist-info/RECORD +139 -0
- crimsonland-0.1.0.dev5.dist-info/WHEEL +4 -0
- crimsonland-0.1.0.dev5.dist-info/entry_points.txt +4 -0
- grim/__init__.py +20 -0
- grim/app.py +92 -0
- grim/assets.py +231 -0
- grim/audio.py +106 -0
- grim/config.py +294 -0
- grim/console.py +737 -0
- grim/fonts/__init__.py +7 -0
- grim/fonts/grim_mono.py +111 -0
- grim/fonts/small.py +120 -0
- grim/input.py +44 -0
- grim/jaz.py +103 -0
- grim/math.py +17 -0
- grim/music.py +403 -0
- grim/paq.py +76 -0
- grim/rand.py +37 -0
- grim/sfx.py +276 -0
- grim/sfx_map.py +103 -0
- grim/terrain_render.py +840 -0
- 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)
|
crimson/player_damage.py
ADDED
|
@@ -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))
|