crimsonland 0.1.0.dev13__py3-none-any.whl → 0.1.0.dev15__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/.DS_Store +0 -0
- crimson/cli.py +2 -0
- crimson/creatures/.DS_Store +0 -0
- crimson/debug.py +9 -0
- crimson/game.py +248 -2
- crimson/gameplay.py +16 -0
- crimson/modes/quest_mode.py +37 -0
- crimson/modes/survival_mode.py +42 -6
- crimson/player_damage.py +2 -0
- crimson/ui/quest_results.py +16 -6
- crimsonland-0.1.0.dev15.dist-info/METADATA +197 -0
- {crimsonland-0.1.0.dev13.dist-info → crimsonland-0.1.0.dev15.dist-info}/RECORD +15 -12
- {crimsonland-0.1.0.dev13.dist-info → crimsonland-0.1.0.dev15.dist-info}/WHEEL +2 -2
- grim/.DS_Store +0 -0
- crimsonland-0.1.0.dev13.dist-info/METADATA +0 -9
- {crimsonland-0.1.0.dev13.dist-info → crimsonland-0.1.0.dev15.dist-info}/entry_points.txt +0 -0
crimson/.DS_Store
ADDED
|
Binary file
|
crimson/cli.py
CHANGED
|
@@ -211,6 +211,7 @@ def cmd_game(
|
|
|
211
211
|
seed: int | None = typer.Option(None, help="rng seed"),
|
|
212
212
|
demo: bool = typer.Option(False, "--demo", help="enable shareware demo mode"),
|
|
213
213
|
no_intro: bool = typer.Option(False, "--no-intro", help="skip company splashes and intro music"),
|
|
214
|
+
debug: bool = typer.Option(False, "--debug", help="enable debug cheats and overlays"),
|
|
214
215
|
base_dir: Path = typer.Option(
|
|
215
216
|
default_runtime_dir(),
|
|
216
217
|
"--base-dir",
|
|
@@ -236,6 +237,7 @@ def cmd_game(
|
|
|
236
237
|
seed=seed,
|
|
237
238
|
demo_enabled=demo,
|
|
238
239
|
no_intro=no_intro,
|
|
240
|
+
debug=debug,
|
|
239
241
|
)
|
|
240
242
|
run_game(config)
|
|
241
243
|
|
|
Binary file
|
crimson/debug.py
CHANGED
|
@@ -2,6 +2,15 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
|
|
5
|
+
_DEBUG_OVERRIDE: bool | None = None
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def set_debug_enabled(enabled: bool) -> None:
|
|
9
|
+
global _DEBUG_OVERRIDE
|
|
10
|
+
_DEBUG_OVERRIDE = bool(enabled)
|
|
11
|
+
|
|
5
12
|
|
|
6
13
|
def debug_enabled() -> bool:
|
|
14
|
+
if _DEBUG_OVERRIDE is not None:
|
|
15
|
+
return bool(_DEBUG_OVERRIDE)
|
|
7
16
|
return os.environ.get("CRIMSON_DEBUG") == "1"
|
crimson/game.py
CHANGED
|
@@ -38,7 +38,7 @@ from grim.terrain_render import GroundRenderer
|
|
|
38
38
|
from grim.view import View, ViewContext
|
|
39
39
|
from grim.fonts.small import SmallFontData, draw_small_text, load_small_font, measure_small_text_width
|
|
40
40
|
|
|
41
|
-
from .debug import debug_enabled
|
|
41
|
+
from .debug import debug_enabled, set_debug_enabled
|
|
42
42
|
from grim import music
|
|
43
43
|
|
|
44
44
|
from .demo import DemoView
|
|
@@ -113,6 +113,7 @@ class GameConfig:
|
|
|
113
113
|
seed: int | None = None
|
|
114
114
|
demo_enabled: bool = False
|
|
115
115
|
no_intro: bool = False
|
|
116
|
+
debug: bool = False
|
|
116
117
|
|
|
117
118
|
|
|
118
119
|
@dataclass(slots=True)
|
|
@@ -190,6 +191,25 @@ QUEST_BACK_BUTTON_X_OFFSET = 138.0
|
|
|
190
191
|
QUEST_BACK_BUTTON_Y_OFFSET = 212.0
|
|
191
192
|
QUEST_PANEL_HEIGHT = 378.0
|
|
192
193
|
|
|
194
|
+
# game_update_victory_screen (0x00406350): used as the "end note" screen after the final quest.
|
|
195
|
+
END_NOTE_PANEL_POS_X = -45.0
|
|
196
|
+
END_NOTE_PANEL_POS_Y = 110.0
|
|
197
|
+
END_NOTE_PANEL_GEOM_X0 = -63.0
|
|
198
|
+
END_NOTE_PANEL_GEOM_Y0 = -81.0
|
|
199
|
+
END_NOTE_PANEL_W = 510.0
|
|
200
|
+
END_NOTE_PANEL_H = 378.0
|
|
201
|
+
|
|
202
|
+
END_NOTE_HEADER_X_OFFSET = 214.0 # v11 + 44 - 10 in the decompile, relative to panel-left
|
|
203
|
+
END_NOTE_HEADER_Y_OFFSET = 46.0 # (base_y + 40) + 6 in the decompile, relative to panel-top
|
|
204
|
+
END_NOTE_BODY_X_OFFSET = END_NOTE_HEADER_X_OFFSET - 8.0
|
|
205
|
+
END_NOTE_BODY_Y_GAP = 32.0
|
|
206
|
+
END_NOTE_LINE_STEP_Y = 14.0
|
|
207
|
+
END_NOTE_AFTER_BODY_Y_GAP = 22.0 # 14 + 8 in the decompile
|
|
208
|
+
|
|
209
|
+
END_NOTE_BUTTON_X_OFFSET = 266.0 # (v11 + 44 + 20) - 4 + 26, relative to panel-left
|
|
210
|
+
END_NOTE_BUTTON_Y_OFFSET = 210.0 # (base_y + 40) + 170 in the decompile, relative to panel-top
|
|
211
|
+
END_NOTE_BUTTON_STEP_Y = 32.0
|
|
212
|
+
|
|
193
213
|
|
|
194
214
|
class QuestsMenuView:
|
|
195
215
|
"""Quest selection menu.
|
|
@@ -282,6 +302,7 @@ class QuestsMenuView:
|
|
|
282
302
|
self._cursor_pulse_time += min(dt, 0.1) * 1.1
|
|
283
303
|
|
|
284
304
|
config = self._state.config
|
|
305
|
+
status = self._state.status
|
|
285
306
|
|
|
286
307
|
# The original forcibly clears hardcore in the demo build.
|
|
287
308
|
if self._state.demo_enabled:
|
|
@@ -289,6 +310,14 @@ class QuestsMenuView:
|
|
|
289
310
|
config.data["hardcore_flag"] = 0
|
|
290
311
|
self._dirty = True
|
|
291
312
|
|
|
313
|
+
if debug_enabled() and rl.is_key_pressed(rl.KeyboardKey.KEY_F5):
|
|
314
|
+
unlock = 49
|
|
315
|
+
if int(status.quest_unlock_index) < unlock:
|
|
316
|
+
status.quest_unlock_index = unlock
|
|
317
|
+
if int(status.quest_unlock_index_full) < unlock:
|
|
318
|
+
status.quest_unlock_index_full = unlock
|
|
319
|
+
self._state.console.log.log("debug: unlocked all quests")
|
|
320
|
+
|
|
292
321
|
if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
|
|
293
322
|
self._action = "open_play_game"
|
|
294
323
|
return
|
|
@@ -374,7 +403,7 @@ class QuestsMenuView:
|
|
|
374
403
|
# `sub_447d40` base sums:
|
|
375
404
|
# x_sum = <ui_element_x> + <ui_element_offset_x> (x=-5)
|
|
376
405
|
# y_sum = <ui_element_y> + <ui_element_offset_y> (y=185 + widescreen shift via ui_menu_layout_init)
|
|
377
|
-
x_sum = QUEST_MENU_BASE_X +
|
|
406
|
+
x_sum = QUEST_MENU_BASE_X + QUEST_MENU_PANEL_OFFSET_X
|
|
378
407
|
y_sum = QUEST_MENU_BASE_Y + MENU_PANEL_OFFSET_Y + self._widescreen_y_shift
|
|
379
408
|
|
|
380
409
|
title_x = x_sum + QUEST_TITLE_X_OFFSET
|
|
@@ -1288,6 +1317,9 @@ class QuestResultsView:
|
|
|
1288
1317
|
self._action = "start_quest"
|
|
1289
1318
|
return
|
|
1290
1319
|
if action == "play_next":
|
|
1320
|
+
if int(self._quest_stage_major) == 5 and int(self._quest_stage_minor) == 10:
|
|
1321
|
+
self._action = "end_note"
|
|
1322
|
+
return
|
|
1291
1323
|
next_level = _next_quest_level(self._quest_level)
|
|
1292
1324
|
if next_level is not None:
|
|
1293
1325
|
self._state.pending_quest_level = next_level
|
|
@@ -1336,6 +1368,217 @@ class QuestResultsView:
|
|
|
1336
1368
|
self._action = "open_high_scores"
|
|
1337
1369
|
|
|
1338
1370
|
|
|
1371
|
+
class EndNoteView:
|
|
1372
|
+
"""Final quest "Show End Note" flow.
|
|
1373
|
+
|
|
1374
|
+
Classic:
|
|
1375
|
+
- quest_results_screen_update uses "Show End Note" instead of "Play Next" for quest 5.10
|
|
1376
|
+
- clicking it transitions to state 0x15 (game_update_victory_screen @ 0x00406350)
|
|
1377
|
+
"""
|
|
1378
|
+
|
|
1379
|
+
def __init__(self, state: GameState) -> None:
|
|
1380
|
+
self._state = state
|
|
1381
|
+
self._ground: GroundRenderer | None = None
|
|
1382
|
+
self._small_font: SmallFontData | None = None
|
|
1383
|
+
self._panel_tex: rl.Texture2D | None = None
|
|
1384
|
+
self._button_textures: UiButtonTextureSet | None = None
|
|
1385
|
+
self._action: str | None = None
|
|
1386
|
+
self._cursor_pulse_time = 0.0
|
|
1387
|
+
|
|
1388
|
+
self._survival_button = UiButtonState("Survival", force_wide=True)
|
|
1389
|
+
self._rush_button = UiButtonState(" Rush ", force_wide=True)
|
|
1390
|
+
self._typo_button = UiButtonState("Typ'o'Shooter", force_wide=True)
|
|
1391
|
+
self._main_menu_button = UiButtonState("Main Menu", force_wide=True)
|
|
1392
|
+
|
|
1393
|
+
def open(self) -> None:
|
|
1394
|
+
self._action = None
|
|
1395
|
+
self._cursor_pulse_time = 0.0
|
|
1396
|
+
self._ground = None if self._state.pause_background is not None else ensure_menu_ground(self._state)
|
|
1397
|
+
|
|
1398
|
+
cache = _ensure_texture_cache(self._state)
|
|
1399
|
+
self._panel_tex = cache.get_or_load("ui_menuPanel", "ui/ui_menuPanel.jaz").texture
|
|
1400
|
+
button_md = cache.get_or_load("ui_buttonMd", "ui/ui_button_128x32.jaz").texture
|
|
1401
|
+
button_sm = cache.get_or_load("ui_buttonSm", "ui/ui_button_64x32.jaz").texture
|
|
1402
|
+
self._button_textures = UiButtonTextureSet(button_sm=button_sm, button_md=button_md)
|
|
1403
|
+
self._small_font = None
|
|
1404
|
+
|
|
1405
|
+
def close(self) -> None:
|
|
1406
|
+
self._ground = None
|
|
1407
|
+
self._small_font = None
|
|
1408
|
+
self._panel_tex = None
|
|
1409
|
+
self._button_textures = None
|
|
1410
|
+
|
|
1411
|
+
def update(self, dt: float) -> None:
|
|
1412
|
+
if self._state.audio is not None:
|
|
1413
|
+
update_audio(self._state.audio, dt)
|
|
1414
|
+
if self._ground is not None:
|
|
1415
|
+
self._ground.process_pending()
|
|
1416
|
+
self._cursor_pulse_time += min(float(dt), 0.1) * 1.1
|
|
1417
|
+
|
|
1418
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
|
|
1419
|
+
if self._state.audio is not None:
|
|
1420
|
+
play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
|
|
1421
|
+
self._action = "back_to_menu"
|
|
1422
|
+
return
|
|
1423
|
+
|
|
1424
|
+
textures = self._button_textures
|
|
1425
|
+
if textures is None or (textures.button_sm is None and textures.button_md is None):
|
|
1426
|
+
return
|
|
1427
|
+
|
|
1428
|
+
screen_w = float(rl.get_screen_width())
|
|
1429
|
+
scale = 1.0
|
|
1430
|
+
|
|
1431
|
+
layout_w = screen_w / scale if scale else screen_w
|
|
1432
|
+
widescreen_shift_y = MenuView._menu_widescreen_y_shift(layout_w)
|
|
1433
|
+
|
|
1434
|
+
panel_left = (END_NOTE_PANEL_GEOM_X0 + END_NOTE_PANEL_POS_X) * scale
|
|
1435
|
+
panel_top = (END_NOTE_PANEL_GEOM_Y0 + END_NOTE_PANEL_POS_Y + widescreen_shift_y) * scale
|
|
1436
|
+
|
|
1437
|
+
button_x = panel_left + END_NOTE_BUTTON_X_OFFSET * scale
|
|
1438
|
+
button_y = panel_top + END_NOTE_BUTTON_Y_OFFSET * scale
|
|
1439
|
+
|
|
1440
|
+
font = self._ensure_small_font()
|
|
1441
|
+
mouse = rl.get_mouse_position()
|
|
1442
|
+
click = rl.is_mouse_button_pressed(rl.MOUSE_BUTTON_LEFT)
|
|
1443
|
+
dt_ms = min(float(dt), 0.1) * 1000.0
|
|
1444
|
+
|
|
1445
|
+
survival_w = button_width(font, self._survival_button.label, scale=scale, force_wide=self._survival_button.force_wide)
|
|
1446
|
+
if button_update(self._survival_button, x=button_x, y=button_y, width=survival_w, dt_ms=dt_ms, mouse=mouse, click=click):
|
|
1447
|
+
self._state.config.data["game_mode"] = 1
|
|
1448
|
+
if self._state.audio is not None:
|
|
1449
|
+
play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
|
|
1450
|
+
self._action = "start_survival"
|
|
1451
|
+
return
|
|
1452
|
+
|
|
1453
|
+
button_y += END_NOTE_BUTTON_STEP_Y * scale
|
|
1454
|
+
rush_w = button_width(font, self._rush_button.label, scale=scale, force_wide=self._rush_button.force_wide)
|
|
1455
|
+
if button_update(self._rush_button, x=button_x, y=button_y, width=rush_w, dt_ms=dt_ms, mouse=mouse, click=click):
|
|
1456
|
+
self._state.config.data["game_mode"] = 2
|
|
1457
|
+
if self._state.audio is not None:
|
|
1458
|
+
play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
|
|
1459
|
+
self._action = "start_rush"
|
|
1460
|
+
return
|
|
1461
|
+
|
|
1462
|
+
button_y += END_NOTE_BUTTON_STEP_Y * scale
|
|
1463
|
+
typo_w = button_width(font, self._typo_button.label, scale=scale, force_wide=self._typo_button.force_wide)
|
|
1464
|
+
if button_update(self._typo_button, x=button_x, y=button_y, width=typo_w, dt_ms=dt_ms, mouse=mouse, click=click):
|
|
1465
|
+
self._state.config.data["game_mode"] = 4
|
|
1466
|
+
self._state.screen_fade_alpha = 0.0
|
|
1467
|
+
self._state.screen_fade_ramp = True
|
|
1468
|
+
if self._state.audio is not None:
|
|
1469
|
+
play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
|
|
1470
|
+
self._action = "start_typo"
|
|
1471
|
+
return
|
|
1472
|
+
|
|
1473
|
+
button_y += END_NOTE_BUTTON_STEP_Y * scale
|
|
1474
|
+
main_w = button_width(font, self._main_menu_button.label, scale=scale, force_wide=self._main_menu_button.force_wide)
|
|
1475
|
+
if button_update(self._main_menu_button, x=button_x, y=button_y, width=main_w, dt_ms=dt_ms, mouse=mouse, click=click):
|
|
1476
|
+
if self._state.audio is not None:
|
|
1477
|
+
play_sfx(self._state.audio, "sfx_ui_buttonclick", rng=self._state.rng)
|
|
1478
|
+
self._action = "back_to_menu"
|
|
1479
|
+
return
|
|
1480
|
+
|
|
1481
|
+
def draw(self) -> None:
|
|
1482
|
+
rl.clear_background(rl.BLACK)
|
|
1483
|
+
pause_background = self._state.pause_background
|
|
1484
|
+
if pause_background is not None:
|
|
1485
|
+
pause_background.draw_pause_background()
|
|
1486
|
+
elif self._ground is not None:
|
|
1487
|
+
self._ground.draw(0.0, 0.0)
|
|
1488
|
+
_draw_screen_fade(self._state)
|
|
1489
|
+
|
|
1490
|
+
panel_tex = self._panel_tex
|
|
1491
|
+
if panel_tex is None:
|
|
1492
|
+
return
|
|
1493
|
+
|
|
1494
|
+
screen_w = float(rl.get_screen_width())
|
|
1495
|
+
scale = 1.0
|
|
1496
|
+
layout_w = screen_w / scale if scale else screen_w
|
|
1497
|
+
widescreen_shift_y = MenuView._menu_widescreen_y_shift(layout_w)
|
|
1498
|
+
|
|
1499
|
+
panel_left = (END_NOTE_PANEL_GEOM_X0 + END_NOTE_PANEL_POS_X) * scale
|
|
1500
|
+
panel_top = (END_NOTE_PANEL_GEOM_Y0 + END_NOTE_PANEL_POS_Y + widescreen_shift_y) * scale
|
|
1501
|
+
panel = rl.Rectangle(
|
|
1502
|
+
float(panel_left),
|
|
1503
|
+
float(panel_top),
|
|
1504
|
+
float(END_NOTE_PANEL_W * scale),
|
|
1505
|
+
float(END_NOTE_PANEL_H * scale),
|
|
1506
|
+
)
|
|
1507
|
+
|
|
1508
|
+
fx_detail = bool(int(self._state.config.data.get("fx_detail_0", 0) or 0))
|
|
1509
|
+
draw_classic_menu_panel(panel_tex, dst=panel, tint=rl.WHITE, shadow=fx_detail)
|
|
1510
|
+
|
|
1511
|
+
font = self._ensure_small_font()
|
|
1512
|
+
hardcore = bool(int(self._state.config.data.get("hardcore_flag", 0) or 0))
|
|
1513
|
+
header = " Incredible!" if hardcore else "Congratulations!"
|
|
1514
|
+
body_lines = (
|
|
1515
|
+
[
|
|
1516
|
+
"You've done the thing we all thought was",
|
|
1517
|
+
"virtually impossible. To reward your",
|
|
1518
|
+
"efforts a new weapon has been unlocked ",
|
|
1519
|
+
"for you: Splitter Gun.",
|
|
1520
|
+
"",
|
|
1521
|
+
"",
|
|
1522
|
+
]
|
|
1523
|
+
if hardcore
|
|
1524
|
+
else [
|
|
1525
|
+
"You've completed all the levels but the battle",
|
|
1526
|
+
"isn't over yet! With all of the unlocked perks",
|
|
1527
|
+
"and weapons your Survival is just a bit easier.",
|
|
1528
|
+
"You can also replay the quests in Hardcore.",
|
|
1529
|
+
"As an additional reward for your victorious",
|
|
1530
|
+
"playing, a completely new and different game",
|
|
1531
|
+
"mode is unlocked for you: Typ'o'Shooter.",
|
|
1532
|
+
]
|
|
1533
|
+
)
|
|
1534
|
+
|
|
1535
|
+
header_x = panel_left + END_NOTE_HEADER_X_OFFSET * scale
|
|
1536
|
+
header_y = panel_top + END_NOTE_HEADER_Y_OFFSET * scale
|
|
1537
|
+
header_color = rl.Color(255, 255, 255, int(255 * 0.8))
|
|
1538
|
+
body_color = rl.Color(255, 255, 255, int(255 * 0.5))
|
|
1539
|
+
|
|
1540
|
+
draw_small_text(font, header, header_x, header_y, 1.5 * scale, header_color)
|
|
1541
|
+
|
|
1542
|
+
body_x = panel_left + END_NOTE_BODY_X_OFFSET * scale
|
|
1543
|
+
body_y = header_y + END_NOTE_BODY_Y_GAP * scale
|
|
1544
|
+
for idx, line in enumerate(body_lines):
|
|
1545
|
+
draw_small_text(font, line, body_x, body_y, 1.0 * scale, body_color)
|
|
1546
|
+
if idx != len(body_lines) - 1:
|
|
1547
|
+
body_y += END_NOTE_LINE_STEP_Y * scale
|
|
1548
|
+
body_y += END_NOTE_AFTER_BODY_Y_GAP * scale
|
|
1549
|
+
draw_small_text(font, "Good luck with your battles, trooper!", body_x, body_y, 1.0 * scale, body_color)
|
|
1550
|
+
|
|
1551
|
+
textures = self._button_textures
|
|
1552
|
+
if textures is not None and (textures.button_sm is not None or textures.button_md is not None):
|
|
1553
|
+
button_x = panel_left + END_NOTE_BUTTON_X_OFFSET * scale
|
|
1554
|
+
button_y = panel_top + END_NOTE_BUTTON_Y_OFFSET * scale
|
|
1555
|
+
survival_w = button_width(font, self._survival_button.label, scale=scale, force_wide=self._survival_button.force_wide)
|
|
1556
|
+
button_draw(textures, font, self._survival_button, x=button_x, y=button_y, width=survival_w, scale=scale)
|
|
1557
|
+
button_y += END_NOTE_BUTTON_STEP_Y * scale
|
|
1558
|
+
rush_w = button_width(font, self._rush_button.label, scale=scale, force_wide=self._rush_button.force_wide)
|
|
1559
|
+
button_draw(textures, font, self._rush_button, x=button_x, y=button_y, width=rush_w, scale=scale)
|
|
1560
|
+
button_y += END_NOTE_BUTTON_STEP_Y * scale
|
|
1561
|
+
typo_w = button_width(font, self._typo_button.label, scale=scale, force_wide=self._typo_button.force_wide)
|
|
1562
|
+
button_draw(textures, font, self._typo_button, x=button_x, y=button_y, width=typo_w, scale=scale)
|
|
1563
|
+
button_y += END_NOTE_BUTTON_STEP_Y * scale
|
|
1564
|
+
main_w = button_width(font, self._main_menu_button.label, scale=scale, force_wide=self._main_menu_button.force_wide)
|
|
1565
|
+
button_draw(textures, font, self._main_menu_button, x=button_x, y=button_y, width=main_w, scale=scale)
|
|
1566
|
+
|
|
1567
|
+
_draw_menu_cursor(self._state, pulse_time=self._cursor_pulse_time)
|
|
1568
|
+
|
|
1569
|
+
def take_action(self) -> str | None:
|
|
1570
|
+
action = self._action
|
|
1571
|
+
self._action = None
|
|
1572
|
+
return action
|
|
1573
|
+
|
|
1574
|
+
def _ensure_small_font(self) -> SmallFontData:
|
|
1575
|
+
if self._small_font is not None:
|
|
1576
|
+
return self._small_font
|
|
1577
|
+
missing_assets: list[str] = []
|
|
1578
|
+
self._small_font = load_small_font(self._state.assets_dir, missing_assets)
|
|
1579
|
+
return self._small_font
|
|
1580
|
+
|
|
1581
|
+
|
|
1339
1582
|
class QuestFailedView:
|
|
1340
1583
|
def __init__(self, state: GameState) -> None:
|
|
1341
1584
|
self._state = state
|
|
@@ -1944,6 +2187,7 @@ class GameLoopView:
|
|
|
1944
2187
|
"start_quest": QuestGameView(state),
|
|
1945
2188
|
"quest_results": QuestResultsView(state),
|
|
1946
2189
|
"quest_failed": QuestFailedView(state),
|
|
2190
|
+
"end_note": EndNoteView(state),
|
|
1947
2191
|
"open_high_scores": HighScoresView(state),
|
|
1948
2192
|
"start_survival": SurvivalGameView(state),
|
|
1949
2193
|
"start_rush": RushGameView(state),
|
|
@@ -2474,6 +2718,8 @@ def _resolve_assets_dir(config: GameConfig) -> Path:
|
|
|
2474
2718
|
|
|
2475
2719
|
|
|
2476
2720
|
def run_game(config: GameConfig) -> None:
|
|
2721
|
+
if config.debug:
|
|
2722
|
+
set_debug_enabled(True)
|
|
2477
2723
|
base_dir = config.base_dir
|
|
2478
2724
|
base_dir.mkdir(parents=True, exist_ok=True)
|
|
2479
2725
|
crash_path = base_dir / "crash.log"
|
crimson/gameplay.py
CHANGED
|
@@ -534,6 +534,7 @@ class GameplayState:
|
|
|
534
534
|
weapon_available: list[bool] = field(default_factory=lambda: [False] * WEAPON_COUNT_SIZE)
|
|
535
535
|
_weapon_available_game_mode: int = -1
|
|
536
536
|
_weapon_available_unlock_index: int = -1
|
|
537
|
+
_weapon_available_unlock_index_full: int = -1
|
|
537
538
|
friendly_fire_enabled: bool = False
|
|
538
539
|
bonus_spawn_guard: bool = False
|
|
539
540
|
bonus_hud: BonusHudState = field(default_factory=BonusHudState)
|
|
@@ -547,6 +548,7 @@ class GameplayState:
|
|
|
547
548
|
shots_fired: list[int] = field(default_factory=lambda: [0] * 4)
|
|
548
549
|
shots_hit: list[int] = field(default_factory=lambda: [0] * 4)
|
|
549
550
|
weapon_shots_fired: list[list[int]] = field(default_factory=lambda: [[0] * WEAPON_COUNT_SIZE for _ in range(4)])
|
|
551
|
+
debug_god_mode: bool = False
|
|
550
552
|
|
|
551
553
|
def __post_init__(self) -> None:
|
|
552
554
|
rand = self.rng.rand
|
|
@@ -1236,17 +1238,23 @@ def weapon_refresh_available(state: "GameplayState") -> None:
|
|
|
1236
1238
|
"""
|
|
1237
1239
|
|
|
1238
1240
|
unlock_index = 0
|
|
1241
|
+
unlock_index_full = 0
|
|
1239
1242
|
status = state.status
|
|
1240
1243
|
if status is not None:
|
|
1241
1244
|
try:
|
|
1242
1245
|
unlock_index = int(status.quest_unlock_index)
|
|
1243
1246
|
except Exception:
|
|
1244
1247
|
unlock_index = 0
|
|
1248
|
+
try:
|
|
1249
|
+
unlock_index_full = int(status.quest_unlock_index_full)
|
|
1250
|
+
except Exception:
|
|
1251
|
+
unlock_index_full = 0
|
|
1245
1252
|
|
|
1246
1253
|
game_mode = int(state.game_mode)
|
|
1247
1254
|
if (
|
|
1248
1255
|
int(state._weapon_available_game_mode) == game_mode
|
|
1249
1256
|
and int(state._weapon_available_unlock_index) == unlock_index
|
|
1257
|
+
and int(state._weapon_available_unlock_index_full) == unlock_index_full
|
|
1250
1258
|
):
|
|
1251
1259
|
return
|
|
1252
1260
|
|
|
@@ -1281,8 +1289,16 @@ def weapon_refresh_available(state: "GameplayState") -> None:
|
|
|
1281
1289
|
if 0 <= idx < len(available):
|
|
1282
1290
|
available[idx] = True
|
|
1283
1291
|
|
|
1292
|
+
# Secret unlock: Splitter Gun (weapon id 29) becomes available once the hardcore
|
|
1293
|
+
# unlock track reaches stage 5 (quest_unlock_index_full >= 40).
|
|
1294
|
+
if (not state.demo_mode_active) and unlock_index_full >= 0x28:
|
|
1295
|
+
splitter_id = int(WeaponId.SPLITTER_GUN)
|
|
1296
|
+
if 0 <= splitter_id < len(available):
|
|
1297
|
+
available[splitter_id] = True
|
|
1298
|
+
|
|
1284
1299
|
state._weapon_available_game_mode = game_mode
|
|
1285
1300
|
state._weapon_available_unlock_index = unlock_index
|
|
1301
|
+
state._weapon_available_unlock_index_full = unlock_index_full
|
|
1286
1302
|
|
|
1287
1303
|
|
|
1288
1304
|
def weapon_pick_random_available(state: "GameplayState") -> int:
|
crimson/modes/quest_mode.py
CHANGED
|
@@ -12,6 +12,7 @@ from grim.config import CrimsonConfig
|
|
|
12
12
|
from grim.fonts.grim_mono import GrimMonoFont, load_grim_mono_font
|
|
13
13
|
from grim.view import ViewContext
|
|
14
14
|
|
|
15
|
+
from ..debug import debug_enabled
|
|
15
16
|
from ..game_modes import GameMode
|
|
16
17
|
from ..gameplay import most_used_weapon_id_for_player, perk_selection_current_choices, perk_selection_pick, weapon_assign_player
|
|
17
18
|
from ..input_codes import config_keybinds, input_code_is_down, input_code_is_pressed, player_move_fire_binds
|
|
@@ -44,6 +45,7 @@ from ..ui.perk_menu import (
|
|
|
44
45
|
wrap_ui_text,
|
|
45
46
|
)
|
|
46
47
|
from ..views.quest_title_overlay import draw_quest_title_overlay
|
|
48
|
+
from ..weapons import WEAPON_BY_ID
|
|
47
49
|
from .base_gameplay_mode import BaseGameplayMode, _clamp
|
|
48
50
|
|
|
49
51
|
WORLD_SIZE = 1024.0
|
|
@@ -78,6 +80,8 @@ PERK_PROMPT_LEVEL_UP_SHIFT_X = -46.0
|
|
|
78
80
|
PERK_PROMPT_LEVEL_UP_SHIFT_Y = -4.0
|
|
79
81
|
|
|
80
82
|
PERK_PROMPT_TEXT_MARGIN_X = 16.0
|
|
83
|
+
|
|
84
|
+
_DEBUG_WEAPON_IDS = tuple(sorted(WEAPON_BY_ID))
|
|
81
85
|
PERK_PROMPT_TEXT_OFFSET_Y = 8.0
|
|
82
86
|
|
|
83
87
|
|
|
@@ -305,10 +309,35 @@ class QuestMode(BaseGameplayMode):
|
|
|
305
309
|
if rl.is_key_pressed(rl.KeyboardKey.KEY_TAB):
|
|
306
310
|
self._paused = not self._paused
|
|
307
311
|
|
|
312
|
+
if debug_enabled() and (not self._perk_menu_open):
|
|
313
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_F2):
|
|
314
|
+
self._state.debug_god_mode = not bool(self._state.debug_god_mode)
|
|
315
|
+
self._world.audio_router.play_sfx("sfx_ui_buttonclick")
|
|
316
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_F3):
|
|
317
|
+
self._state.perk_selection.pending_count += 1
|
|
318
|
+
self._state.perk_selection.choices_dirty = True
|
|
319
|
+
self._world.audio_router.play_sfx("sfx_ui_levelup")
|
|
320
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT_BRACKET):
|
|
321
|
+
self._debug_cycle_weapon(-1)
|
|
322
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT_BRACKET):
|
|
323
|
+
self._debug_cycle_weapon(1)
|
|
324
|
+
|
|
308
325
|
if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
|
|
309
326
|
self._action = "open_pause_menu"
|
|
310
327
|
return
|
|
311
328
|
|
|
329
|
+
def _debug_cycle_weapon(self, delta: int) -> None:
|
|
330
|
+
weapon_ids = _DEBUG_WEAPON_IDS
|
|
331
|
+
if not weapon_ids:
|
|
332
|
+
return
|
|
333
|
+
current = int(self._player.weapon_id)
|
|
334
|
+
try:
|
|
335
|
+
idx = weapon_ids.index(current)
|
|
336
|
+
except ValueError:
|
|
337
|
+
idx = 0
|
|
338
|
+
weapon_id = int(weapon_ids[(idx + int(delta)) % len(weapon_ids)])
|
|
339
|
+
weapon_assign_player(self._player, weapon_id, state=self._state)
|
|
340
|
+
|
|
312
341
|
def _build_input(self):
|
|
313
342
|
keybinds = config_keybinds(self._config)
|
|
314
343
|
if not keybinds:
|
|
@@ -875,6 +904,12 @@ class QuestMode(BaseGameplayMode):
|
|
|
875
904
|
small_indicators=self._hud_small_indicators(),
|
|
876
905
|
)
|
|
877
906
|
|
|
907
|
+
if debug_enabled() and (not perk_menu_active):
|
|
908
|
+
x = 18.0
|
|
909
|
+
y = max(18.0, hud_bottom + 10.0)
|
|
910
|
+
god = "on" if self._state.debug_god_mode else "off"
|
|
911
|
+
self._draw_ui_text(f"debug: [/] weapon F3 perk+1 F2 god={god}", x, y, UI_HINT_COLOR, scale=0.9)
|
|
912
|
+
|
|
878
913
|
self._draw_quest_title()
|
|
879
914
|
|
|
880
915
|
warn_y = float(rl.get_screen_height()) - 28.0
|
|
@@ -895,6 +930,8 @@ class QuestMode(BaseGameplayMode):
|
|
|
895
930
|
self._draw_game_cursor()
|
|
896
931
|
x = 18.0
|
|
897
932
|
y = max(18.0, hud_bottom + 10.0)
|
|
933
|
+
if debug_enabled() and (not perk_menu_active):
|
|
934
|
+
y += float(self._ui_line_height(scale=0.9))
|
|
898
935
|
self._draw_ui_text("paused (TAB)", x, y, UI_HINT_COLOR)
|
|
899
936
|
else:
|
|
900
937
|
self._draw_aim_cursor()
|
crimson/modes/survival_mode.py
CHANGED
|
@@ -14,7 +14,14 @@ from grim.view import ViewContext
|
|
|
14
14
|
from ..creatures.spawn import advance_survival_spawn_stage, tick_survival_wave_spawns
|
|
15
15
|
from ..debug import debug_enabled
|
|
16
16
|
from ..game_modes import GameMode
|
|
17
|
-
from ..gameplay import
|
|
17
|
+
from ..gameplay import (
|
|
18
|
+
PlayerInput,
|
|
19
|
+
most_used_weapon_id_for_player,
|
|
20
|
+
perk_selection_current_choices,
|
|
21
|
+
perk_selection_pick,
|
|
22
|
+
survival_check_level_up,
|
|
23
|
+
weapon_assign_player,
|
|
24
|
+
)
|
|
18
25
|
from ..persistence.highscores import HighScoreRecord
|
|
19
26
|
from ..perks import PerkId, perk_display_description, perk_display_name
|
|
20
27
|
from ..ui.cursor import draw_aim_cursor, draw_menu_cursor
|
|
@@ -38,6 +45,7 @@ from ..ui.perk_menu import (
|
|
|
38
45
|
ui_scale,
|
|
39
46
|
wrap_ui_text,
|
|
40
47
|
)
|
|
48
|
+
from ..weapons import WEAPON_BY_ID
|
|
41
49
|
from .base_gameplay_mode import BaseGameplayMode, _clamp
|
|
42
50
|
|
|
43
51
|
WORLD_SIZE = 1024.0
|
|
@@ -71,6 +79,8 @@ PERK_PROMPT_LEVEL_UP_SHIFT_Y = -4.0
|
|
|
71
79
|
PERK_PROMPT_TEXT_MARGIN_X = 16.0
|
|
72
80
|
PERK_PROMPT_TEXT_OFFSET_Y = 8.0
|
|
73
81
|
|
|
82
|
+
_DEBUG_WEAPON_IDS = tuple(sorted(WEAPON_BY_ID))
|
|
83
|
+
|
|
74
84
|
|
|
75
85
|
@dataclass(slots=True)
|
|
76
86
|
class _SurvivalState:
|
|
@@ -181,14 +191,38 @@ class SurvivalMode(BaseGameplayMode):
|
|
|
181
191
|
if rl.is_key_pressed(rl.KeyboardKey.KEY_TAB):
|
|
182
192
|
self._paused = not self._paused
|
|
183
193
|
|
|
184
|
-
if debug_enabled() and
|
|
185
|
-
|
|
186
|
-
|
|
194
|
+
if debug_enabled() and (not self._perk_menu_open):
|
|
195
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_F2):
|
|
196
|
+
self._state.debug_god_mode = not bool(self._state.debug_god_mode)
|
|
197
|
+
self._world.audio_router.play_sfx("sfx_ui_buttonclick")
|
|
198
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_F3):
|
|
199
|
+
self._state.perk_selection.pending_count += 1
|
|
200
|
+
self._state.perk_selection.choices_dirty = True
|
|
201
|
+
self._world.audio_router.play_sfx("sfx_ui_levelup")
|
|
202
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_LEFT_BRACKET):
|
|
203
|
+
self._debug_cycle_weapon(-1)
|
|
204
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_RIGHT_BRACKET):
|
|
205
|
+
self._debug_cycle_weapon(1)
|
|
206
|
+
if rl.is_key_pressed(rl.KeyboardKey.KEY_X):
|
|
207
|
+
self._player.experience += 5000
|
|
208
|
+
survival_check_level_up(self._player, self._state.perk_selection)
|
|
187
209
|
|
|
188
210
|
if rl.is_key_pressed(rl.KeyboardKey.KEY_ESCAPE):
|
|
189
211
|
self._action = "open_pause_menu"
|
|
190
212
|
return
|
|
191
213
|
|
|
214
|
+
def _debug_cycle_weapon(self, delta: int) -> None:
|
|
215
|
+
weapon_ids = _DEBUG_WEAPON_IDS
|
|
216
|
+
if not weapon_ids:
|
|
217
|
+
return
|
|
218
|
+
current = int(self._player.weapon_id)
|
|
219
|
+
try:
|
|
220
|
+
idx = weapon_ids.index(current)
|
|
221
|
+
except ValueError:
|
|
222
|
+
idx = 0
|
|
223
|
+
weapon_id = int(weapon_ids[(idx + int(delta)) % len(weapon_ids)])
|
|
224
|
+
weapon_assign_player(self._player, weapon_id, state=self._state)
|
|
225
|
+
|
|
192
226
|
def _build_input(self) -> PlayerInput:
|
|
193
227
|
keybinds = config_keybinds(self._config)
|
|
194
228
|
if not keybinds:
|
|
@@ -779,10 +813,12 @@ class SurvivalMode(BaseGameplayMode):
|
|
|
779
813
|
line = float(self._ui_line_height())
|
|
780
814
|
self._draw_ui_text(f"survival: t={self._survival.elapsed_ms/1000.0:6.1f}s stage={self._survival.stage}", x, y, UI_TEXT_COLOR)
|
|
781
815
|
self._draw_ui_text(f"xp={self._player.experience} level={self._player.level} kills={self._creatures.kill_count}", x, y + line, UI_HINT_COLOR)
|
|
816
|
+
god = "on" if self._state.debug_god_mode else "off"
|
|
817
|
+
self._draw_ui_text(f"debug: [/] weapon F3 perk+1 F2 god={god} X xp+5000", x, y + line * 2.0, UI_HINT_COLOR, scale=0.9)
|
|
782
818
|
if self._paused:
|
|
783
|
-
self._draw_ui_text("paused (TAB)", x, y + line *
|
|
819
|
+
self._draw_ui_text("paused (TAB)", x, y + line * 3.0, UI_HINT_COLOR)
|
|
784
820
|
if self._player.health <= 0.0:
|
|
785
|
-
self._draw_ui_text("game over", x, y + line *
|
|
821
|
+
self._draw_ui_text("game over", x, y + line * 3.0, UI_ERROR_COLOR)
|
|
786
822
|
warn_y = float(rl.get_screen_height()) - 28.0
|
|
787
823
|
if self._world.missing_assets:
|
|
788
824
|
warn = "Missing world assets: " + ", ".join(self._world.missing_assets)
|
crimson/player_damage.py
CHANGED
crimson/ui/quest_results.py
CHANGED
|
@@ -73,6 +73,14 @@ QUEST_RESULTS_PANEL_H = 378.0
|
|
|
73
73
|
TEXTURE_TOP_BANNER_W = 256.0
|
|
74
74
|
TEXTURE_TOP_BANNER_H = 64.0
|
|
75
75
|
|
|
76
|
+
# `quest_results_screen_update` uses the classic UI element sums for positioning:
|
|
77
|
+
# content_x = (pos_x + offset_x + slide_x) + 180.0 + 40.0
|
|
78
|
+
# banner_x = content_x - 18.0
|
|
79
|
+
# score_x = content_x + 30.0
|
|
80
|
+
QUEST_RESULTS_CONTENT_X = 220.0
|
|
81
|
+
QUEST_RESULTS_BANNER_X_FROM_CONTENT = -18.0
|
|
82
|
+
QUEST_RESULTS_SCORE_CARD_X_FROM_CONTENT = 30.0
|
|
83
|
+
|
|
76
84
|
INPUT_BOX_W = 166.0
|
|
77
85
|
INPUT_BOX_H = 18.0
|
|
78
86
|
|
|
@@ -399,7 +407,7 @@ class QuestResultsUi:
|
|
|
399
407
|
screen_h = float(rl.get_screen_height())
|
|
400
408
|
scale = ui_scale(screen_w, screen_h)
|
|
401
409
|
_panel, panel_left, panel_top = self._panel_layout(screen_w=screen_w, scale=scale)
|
|
402
|
-
anchor_x = panel_left +
|
|
410
|
+
anchor_x = panel_left + QUEST_RESULTS_CONTENT_X * scale
|
|
403
411
|
input_y = panel_top + 150.0 * scale
|
|
404
412
|
ok_x = anchor_x + 170.0 * scale
|
|
405
413
|
ok_y = input_y - 8.0 * scale
|
|
@@ -451,7 +459,8 @@ class QuestResultsUi:
|
|
|
451
459
|
_origin_x, _origin_y = ui_origin(screen_w, screen_h, scale)
|
|
452
460
|
_panel, left, top = self._panel_layout(screen_w=screen_w, scale=scale)
|
|
453
461
|
qualifies = int(self.rank) < TABLE_MAX
|
|
454
|
-
|
|
462
|
+
content_x = left + QUEST_RESULTS_CONTENT_X * scale
|
|
463
|
+
score_card_x = content_x + QUEST_RESULTS_SCORE_CARD_X_FROM_CONTENT * scale
|
|
455
464
|
|
|
456
465
|
var_c_12 = top + (96.0 if qualifies else 108.0) * scale
|
|
457
466
|
var_c_14 = var_c_12 + 84.0 * scale
|
|
@@ -515,7 +524,8 @@ class QuestResultsUi:
|
|
|
515
524
|
fx_detail = bool(int(self.config.data.get("fx_detail_0", 0) or 0))
|
|
516
525
|
draw_classic_menu_panel(self.assets.menu_panel, dst=panel, tint=rl.WHITE, shadow=fx_detail)
|
|
517
526
|
|
|
518
|
-
|
|
527
|
+
content_x = left + QUEST_RESULTS_CONTENT_X * scale
|
|
528
|
+
banner_x = content_x + QUEST_RESULTS_BANNER_X_FROM_CONTENT * scale
|
|
519
529
|
banner_y = top + 36.0 * scale
|
|
520
530
|
if self.assets.text_well_done is not None:
|
|
521
531
|
src = rl.Rectangle(0.0, 0.0, float(self.assets.text_well_done.width), float(self.assets.text_well_done.height))
|
|
@@ -525,7 +535,7 @@ class QuestResultsUi:
|
|
|
525
535
|
qualifies = int(self.rank) < TABLE_MAX
|
|
526
536
|
|
|
527
537
|
if self.phase == 0:
|
|
528
|
-
anchor_x =
|
|
538
|
+
anchor_x = content_x
|
|
529
539
|
label_x = anchor_x + 32.0 * scale
|
|
530
540
|
value_x = label_x + 132.0 * scale
|
|
531
541
|
|
|
@@ -587,7 +597,7 @@ class QuestResultsUi:
|
|
|
587
597
|
self._draw_small(final_value, value_x, y, 1.0 * scale, _row_color(3, final=True))
|
|
588
598
|
|
|
589
599
|
elif self.phase == 1:
|
|
590
|
-
anchor_x =
|
|
600
|
+
anchor_x = content_x
|
|
591
601
|
text_y = top + 118.0 * scale
|
|
592
602
|
self._draw_small("State your name trooper!", anchor_x + 42.0 * scale, text_y, 1.0 * scale, COLOR_TEXT)
|
|
593
603
|
|
|
@@ -615,7 +625,7 @@ class QuestResultsUi:
|
|
|
615
625
|
button_draw(self.assets.perk_menu_assets, self.font, self._ok_button, x=ok_x, y=ok_y, width=ok_w, scale=scale)
|
|
616
626
|
|
|
617
627
|
else:
|
|
618
|
-
score_card_x =
|
|
628
|
+
score_card_x = content_x + QUEST_RESULTS_SCORE_CARD_X_FROM_CONTENT * scale
|
|
619
629
|
var_c_12 = top + (96.0 if qualifies else 108.0) * scale
|
|
620
630
|
if (not qualifies) and self.font is not None:
|
|
621
631
|
self._draw_small(
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: crimsonland
|
|
3
|
+
Version: 0.1.0.dev15
|
|
4
|
+
Requires-Dist: construct>=2.10.70
|
|
5
|
+
Requires-Dist: pillow>=12.1.0
|
|
6
|
+
Requires-Dist: platformdirs>=4.5.1
|
|
7
|
+
Requires-Dist: raylib>=5.5.0.4
|
|
8
|
+
Requires-Dist: typer>=0.21.1
|
|
9
|
+
Requires-Python: >=3.13
|
|
10
|
+
Project-URL: Documentation, https://crimson.banteg.xyz/
|
|
11
|
+
Project-URL: Repository, https://github.com/banteg/crimson
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# Crimsonland 1.9.93 decompilation + rewrite
|
|
15
|
+
|
|
16
|
+
This repository is a **reverse engineering + high‑fidelity reimplementation** of **Crimsonland 1.9.93 (2003)**.
|
|
17
|
+
|
|
18
|
+
- **Target build:** `v1.9.93` (GOG "Crimsonland Classic") — see [docs/provenance.md](docs/provenance.md) for exact hashes.
|
|
19
|
+
- **Rewrite:** a runnable reference implementation in **Python + raylib** under `src/`.
|
|
20
|
+
- **Analysis:** decompiles, name/type maps, and runtime evidence under `analysis/`.
|
|
21
|
+
- **Docs:** long-form notes and parity tracking under `docs/` (start at [docs/index.md](docs/index.md)).
|
|
22
|
+
|
|
23
|
+
The north star is **behavioral parity** with the original Windows build: timings, RNG, UI/layout quirks, asset decoding, and gameplay rules should match as closely as practical.
|
|
24
|
+
|
|
25
|
+
**[Read the full story](https://banteg.xyz/posts/crimsonland/)** of how this project came together: reverse engineering workflow, custom asset formats, AI-assisted decompilation, and game preservation philosophy.
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Quick start
|
|
30
|
+
|
|
31
|
+
Install [uv](https://docs.astral.sh/uv/getting-started/installation/) package manager.
|
|
32
|
+
|
|
33
|
+
### Run the latest packaged build
|
|
34
|
+
|
|
35
|
+
If you just want to play the rewrite:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
uvx crimsonland@latest
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Run from a checkout
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
gh repo clone banteg/crimson
|
|
45
|
+
cd crimson
|
|
46
|
+
uv run crimson
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Keep runtime files local to the repo
|
|
50
|
+
|
|
51
|
+
By default, runtime files (e.g. `crimson.cfg`, `game.cfg`, highscores, logs, downloaded PAQs) live in your per-user data dir.
|
|
52
|
+
To keep everything under this checkout:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
export CRIMSON_RUNTIME_DIR="$PWD/artifacts/runtime"
|
|
56
|
+
mkdir -p artifacts/runtime
|
|
57
|
+
uv run crimson
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## Assets + binaries
|
|
63
|
+
|
|
64
|
+
There are two separate “inputs” to this repo:
|
|
65
|
+
|
|
66
|
+
1. **Runtime assets for the rewrite** (PAQ archives)
|
|
67
|
+
2. **Original Windows binaries for reverse engineering** (`crimsonland.exe`, `grim.dll`, …)
|
|
68
|
+
|
|
69
|
+
We keep them out of git and expect a local layout like:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
game_bins/
|
|
73
|
+
crimsonland/
|
|
74
|
+
1.9.93-gog/
|
|
75
|
+
crimsonland.exe
|
|
76
|
+
grim.dll
|
|
77
|
+
crimson.paq
|
|
78
|
+
music.paq
|
|
79
|
+
sfx.paq
|
|
80
|
+
artifacts/
|
|
81
|
+
runtime/ # optional: where you run the rewrite (cfg/status/paqs)
|
|
82
|
+
assets/ # optional: extracted PAQs for inspection/tools
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Running the rewrite
|
|
86
|
+
|
|
87
|
+
The rewrite loads the assets from original archives:
|
|
88
|
+
|
|
89
|
+
- `crimson.paq`
|
|
90
|
+
- `music.paq`
|
|
91
|
+
- `sfx.paq`
|
|
92
|
+
|
|
93
|
+
### Extracted assets
|
|
94
|
+
|
|
95
|
+
For inspection/diffs/tools, you can extract PAQs into a filesystem tree:
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
uv run crimson extract crimsonland_1.9.93 artifacts/assets
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Same as the original, many loaders can work from either:
|
|
102
|
+
|
|
103
|
+
- **PAQ-backed assets** (preferred when available), or
|
|
104
|
+
- the **extracted filesystem layout** under `artifacts/assets/`.
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## CLI cheat sheet
|
|
109
|
+
|
|
110
|
+
Everything is exposed via the `crimson` CLI (alias: `crimsonland`):
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv run crimson # run the game (default command)
|
|
114
|
+
uv run crimson view ui # debug views / sandboxes
|
|
115
|
+
uv run crimson quests 1.1 # print quest spawn script
|
|
116
|
+
uv run crimson config # inspect crimson.cfg
|
|
117
|
+
uv run crimson extract <game_dir> artifacts/assets
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Useful flags:
|
|
121
|
+
|
|
122
|
+
- `--base-dir PATH` / `CRIMSON_RUNTIME_DIR=...` — where saves/config/logs live
|
|
123
|
+
- `--assets-dir PATH` — where `.paq` archives (or extracted assets) are loaded from
|
|
124
|
+
- `--seed N` — deterministic runs for parity testing
|
|
125
|
+
- `--demo` — enable shareware/demo paths
|
|
126
|
+
- `--no-intro` — skip logos/intro music
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Docs
|
|
131
|
+
|
|
132
|
+
Docs are authored in `docs/` and built as a static site at https://crimson.banteg.xyz/
|
|
133
|
+
|
|
134
|
+
For development, it's useful to have a live local build:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
uv tool install zensical
|
|
138
|
+
zensical serve
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
---
|
|
142
|
+
|
|
143
|
+
## Development
|
|
144
|
+
|
|
145
|
+
### Tests
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
uv run pytest
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Lint / checks
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
uv run lint-imports
|
|
155
|
+
uv run python scripts/check_asset_loader_usage.py
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
### `justfile` shortcuts
|
|
159
|
+
|
|
160
|
+
If you have `just` installed:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
just --list
|
|
164
|
+
just test
|
|
165
|
+
just docs-build
|
|
166
|
+
just ghidra-exe
|
|
167
|
+
just ghidra-grim
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Reverse engineering workflow
|
|
173
|
+
|
|
174
|
+
High level:
|
|
175
|
+
|
|
176
|
+
- **Static analysis is the source of truth.**
|
|
177
|
+
- Update names/types in [analysis/ghidra/maps/](analysis/ghidra/maps/).
|
|
178
|
+
- Treat [analysis/ghidra/raw/](analysis/ghidra/raw/) as generated output (regenerate; do not hand-edit).
|
|
179
|
+
- **Runtime tooling** (Frida / WinDbg) validates ambiguous behavior and captures ground truth.
|
|
180
|
+
- Evidence summaries live under [analysis/frida/](analysis/frida/).
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Contributing notes
|
|
185
|
+
|
|
186
|
+
- Keep changes small and reviewable (one subsystem/feature at a time).
|
|
187
|
+
- Prefer *measured parity* (captures/logs/deterministic tests) over “looks right”.
|
|
188
|
+
- When porting float constants from decompilation, prefer the intended value
|
|
189
|
+
(e.g. `0.6` instead of `0.6000000238418579` when it’s clearly a float32 artifact).
|
|
190
|
+
|
|
191
|
+
---
|
|
192
|
+
|
|
193
|
+
## Legal
|
|
194
|
+
|
|
195
|
+
This project is an independent reverse engineering and reimplementation effort for preservation, research, and compatibility.
|
|
196
|
+
|
|
197
|
+
No original assets or binaries are included. Use your own legally obtained copy.
|
|
@@ -1,17 +1,19 @@
|
|
|
1
|
+
crimson/.DS_Store,sha256=wzeuDdLmWdcG-N9ol7JzTcv0_ctH28Aaeq1C1fe8ZYw,6148
|
|
1
2
|
crimson/__init__.py,sha256=dij6OQ6Wctqur9P00ojTTZw6IaUNeZagPX4-2Qqr-Kw,367
|
|
2
3
|
crimson/assets_fetch.py,sha256=ScrxzSkbs0TkaUtIi-WLC4NN1nIL9J8RloSH_Uz4HGo,2356
|
|
3
4
|
crimson/atlas.py,sha256=hEcCHhPvguXAI6eH_G9Q8rpiX7M5akZ8fgJjMogmYrA,2401
|
|
4
5
|
crimson/audio_router.py,sha256=4lccGu5044WQ5sMz9yfZd4loSgEMDqXJWGvMmHyMGt0,5449
|
|
5
6
|
crimson/bonuses.py,sha256=owwYIRHRu1Kymtt4eEvpd62JwWAg8LOe20vDuPFB5SU,5094
|
|
6
7
|
crimson/camera.py,sha256=VxTNXTh0qHh5VR1OpkiXr-QcgEWPrWw0A3PFyQFqDkY,2278
|
|
7
|
-
crimson/cli.py,sha256=
|
|
8
|
+
crimson/cli.py,sha256=3OvfDrVJyeiL0hjEYRAxfy2u4Yle36xNAm8ZkbvnkLo,14707
|
|
9
|
+
crimson/creatures/.DS_Store,sha256=sAyNySnK01L93nKof3NyiBn3DTcel6MPhjblBP1p1Uo,6148
|
|
8
10
|
crimson/creatures/__init__.py,sha256=RJwwR_w98zC_kHF9CwkhzSlf0xjpiP0JU87W4awdAAM,233
|
|
9
11
|
crimson/creatures/ai.py,sha256=oCuUkDe6Kxya1dThUthEy8GIuObAUjDv8WPfe17HYV4,6353
|
|
10
12
|
crimson/creatures/anim.py,sha256=k_jEpyfwFcJSC9jY2xuhuxtQ8KvE6fSeuRSBLTTAxWo,5016
|
|
11
13
|
crimson/creatures/damage.py,sha256=pjKIX32nGDVPnFaWCce0LNZ-UZXZqJpNvwHwq-DCWbE,3211
|
|
12
14
|
crimson/creatures/runtime.py,sha256=wfSicwqkSovM_mn_7AMQwxWY_-pY1u6Qa1E_qmQ8c94,41490
|
|
13
15
|
crimson/creatures/spawn.py,sha256=ikgtr4sM2KdA2-eyxYdVmobcuZN6aA7xK7ceaIl7RSw,90059
|
|
14
|
-
crimson/debug.py,sha256=
|
|
16
|
+
crimson/debug.py,sha256=Hcg0I4oHCtbwDMacnhCnY9gRmueorVctAOpuVgFmwcQ,348
|
|
15
17
|
crimson/demo.py,sha256=5sjQiUGGohm7t0s69-My_S9HwG84T3jDCl682UsffOE,55053
|
|
16
18
|
crimson/demo_trial.py,sha256=BuoKB1DB-cPZ8jBp4x9gmxWF6tvhXMYRqJ9hMYrhxH4,4651
|
|
17
19
|
crimson/effects.py,sha256=56YajT1g3MF1UOly1nsjbT0wkKusjAeyalT3eXFBQIE,35384
|
|
@@ -32,16 +34,16 @@ crimson/frontend/panels/play_game.py,sha256=BiO8oDhb6YQZqs3nZEJqUvajMeC6VRQ577do
|
|
|
32
34
|
crimson/frontend/panels/stats.py,sha256=1-q9TezFVPADkTmPSAg_c_drkrlMOY3jYHjtAm3XGNE,12573
|
|
33
35
|
crimson/frontend/pause_menu.py,sha256=iM08blMUbgFnV-aGpTHxwieqSQzCnsS66WFMT3lr5vI,16365
|
|
34
36
|
crimson/frontend/transitions.py,sha256=-sAJUDqNZ943zXlqtvJ6jCg2YH8dSi8k7qK8caAfOKI,883
|
|
35
|
-
crimson/game.py,sha256=
|
|
37
|
+
crimson/game.py,sha256=juwTTwSBB2xBhFprsGpqOYrVSnIkULsOucNOAPwfpIk,110800
|
|
36
38
|
crimson/game_modes.py,sha256=qW7Tt97lSBmGrt0F17Ni5h8vRyngBzyS9XwWM1TFIEI,255
|
|
37
39
|
crimson/game_world.py,sha256=nfKGcm3LHChPGLHJsurDFAATrHmhRvTmgxcLzUN9m5I,25440
|
|
38
|
-
crimson/gameplay.py,sha256=
|
|
40
|
+
crimson/gameplay.py,sha256=cghfd7Z3xYDOdI06de6qMPRLlPMVN3wOw2B6wf-cRaE,91602
|
|
39
41
|
crimson/input_codes.py,sha256=PmSWFZIit8unTBWk3uwifpHWMuk0qMg1ueKX3MGC7D0,5379
|
|
40
42
|
crimson/modes/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
|
|
41
43
|
crimson/modes/base_gameplay_mode.py,sha256=0hAtdpXLYuhP304qC1GvbQ2rBAALpJTgScDgOs_nRwU,10190
|
|
42
|
-
crimson/modes/quest_mode.py,sha256=
|
|
44
|
+
crimson/modes/quest_mode.py,sha256=90SPy0Q4TR8NHZaWfAK0PiAibuIrWwqYSxgvvqAlzv8,40039
|
|
43
45
|
crimson/modes/rush_mode.py,sha256=l4TAvjOvOA-Z-liMDXp_HGfszfghTmJUyVSkiPkfHQ0,11527
|
|
44
|
-
crimson/modes/survival_mode.py,sha256=
|
|
46
|
+
crimson/modes/survival_mode.py,sha256=LmCwkLxCoMPraeywNxF7h14eo9k4mr1awGWDyrbJO_k,35120
|
|
45
47
|
crimson/modes/tutorial_mode.py,sha256=rWrVSCjM8G-fHn1_0iVWIzGTeLnCFSNktuoRNSX3cg4,27634
|
|
46
48
|
crimson/modes/typo_mode.py,sha256=G3zpg2J7WafXpIYsVV1LeO1gjCCmb40_cbxa_m2kx0U,17957
|
|
47
49
|
crimson/paths.py,sha256=JCKauCxHJnP4IIy5ocgs6tvbaSMkMUByQ76Zoz2UGZM,653
|
|
@@ -49,7 +51,7 @@ crimson/perks.py,sha256=JHNhKPOUq2w8h23gU7jpkx-erFKTO4Z8l8aHU8vrkO0,37068
|
|
|
49
51
|
crimson/persistence/__init__.py,sha256=U4S_2y3zgLZVfMenHRaJFBW8yqh2mUBuI291LGQVOJ8,35
|
|
50
52
|
crimson/persistence/highscores.py,sha256=FyWXTU8faNxYshkbn7Cf3lrxUxiI1VcgGZOq9U9dT8g,13114
|
|
51
53
|
crimson/persistence/save_status.py,sha256=s-FZONO6JVYRULewtlxoIwATTy3P7rLy-vsVBfSKPk4,7748
|
|
52
|
-
crimson/player_damage.py,sha256=
|
|
54
|
+
crimson/player_damage.py,sha256=UyGs6SjJBxB6zECr78jUvI7LlGRg0E4kOBESSLBcyaY,2263
|
|
53
55
|
crimson/projectiles.py,sha256=56yocC2XYC69bixrmAgHud4YiSIgezMf2blCQw_YekM,51304
|
|
54
56
|
crimson/quests/__init__.py,sha256=pKCkH0o1XIGDN9h6yNNaqvWek2CybEZRpejYRooULj8,375
|
|
55
57
|
crimson/quests/helpers.py,sha256=TeZWhIy2x8Zs9S-vYRBOEZ7pkHNMr5YJ-wi4vYQ2q2M,3625
|
|
@@ -84,7 +86,7 @@ crimson/ui/game_over.py,sha256=pkHDGE47yBraHY-BTnZzOFdRGUJ4ravF0cL1HBdA300,30267
|
|
|
84
86
|
crimson/ui/hud.py,sha256=sVJhh7iAmDISMAcB2AALw1tM0828mlddpAFUXJm8E8c,31123
|
|
85
87
|
crimson/ui/menu_panel.py,sha256=ko1NErvQ5Ystq4f-AL0_eB6FaWoT_n3uonX-X1Ya_6o,4578
|
|
86
88
|
crimson/ui/perk_menu.py,sha256=Idz7Ps8JzhBqUZQQH-n1dS_DviX5LDWRrhWFO0Qc9Ac,13787
|
|
87
|
-
crimson/ui/quest_results.py,sha256=
|
|
89
|
+
crimson/ui/quest_results.py,sha256=PLq5XD6hAdQf3qpHWZe2OhiIv7TdC38qr8alPasq4LM,29721
|
|
88
90
|
crimson/ui/shadow.py,sha256=P6B8lZCu8W0YPhLcqi8H413D5oSNeJYwnroYAw4ZGYI,1090
|
|
89
91
|
crimson/views/__init__.py,sha256=1UWCzBSsgIeOtcKxqM4Vf12JEfPHKfb9KRZgl2TB84Q,1880
|
|
90
92
|
crimson/views/aim_debug.py,sha256=2ldQ1MJO8AeG0i9x4ZJ3YLyp1X-HY_nJ0E1mY8jFF7Q,10873
|
|
@@ -121,6 +123,7 @@ crimson/views/ui.py,sha256=2gGyCiI_JnlHzG9CWH8nomQIgCthfLwP7bTsDyqAyx4,4294
|
|
|
121
123
|
crimson/views/wicons.py,sha256=bGbmE25q_pYJZTiPt6Q5Sy1Ub667KvUIFp1a5rVxhi0,5783
|
|
122
124
|
crimson/weapon_sfx.py,sha256=_JAFpXi-c7jFNz3hxKygAYwqAR6zC94QnNHQYDCHnOY,1717
|
|
123
125
|
crimson/weapons.py,sha256=dfKiVrLGw6EaUo1K0-twDowA4S7tFoPy_sRTENrWJGI,22084
|
|
126
|
+
grim/.DS_Store,sha256=QLue9_bBPqaUhApqMqZyfVv_whyhZNPO1nSyW8sjcpo,6148
|
|
124
127
|
grim/__init__.py,sha256=Cn4f7JY6bscS1gwjDPaWvIvj_vneuUXaiu7wdJB6EVg,262
|
|
125
128
|
grim/app.py,sha256=WQfJLaCn7-NT1SFQs7Fwm7EgYySiVZkJsf_NPDoiR0o,2798
|
|
126
129
|
grim/assets.py,sha256=k2rFqNYfcUZfRjFL7r_UBGf1RlcP-urChXCUJ6GzEsI,7661
|
|
@@ -140,7 +143,7 @@ grim/sfx.py,sha256=cpn2Mmeio7BSDgbStSft-eZchO9Ot2MrK6iXJqxlLqU,7836
|
|
|
140
143
|
grim/sfx_map.py,sha256=FM5iBzKkG30Vtu78SRavVNgXMbGK7ZFcQ8i6lgMlzVw,4697
|
|
141
144
|
grim/terrain_render.py,sha256=EZ7ySYJyTZwXcrJx1mKbY3ewZtPi7Y270XnZgGJyZG8,31509
|
|
142
145
|
grim/view.py,sha256=oF4pHZehBqOxPjKMU28TDg3qATh_amMIRJp-vMQnpn4,334
|
|
143
|
-
crimsonland-0.1.0.
|
|
144
|
-
crimsonland-0.1.0.
|
|
145
|
-
crimsonland-0.1.0.
|
|
146
|
-
crimsonland-0.1.0.
|
|
146
|
+
crimsonland-0.1.0.dev15.dist-info/WHEEL,sha256=ZyFSCYkV2BrxH6-HRVRg3R9Fo7MALzer9KiPYqNxSbo,79
|
|
147
|
+
crimsonland-0.1.0.dev15.dist-info/entry_points.txt,sha256=jzzcExxiE9xpt4Iw2nbB1lwTv2Zj4H14WJTIPMkAjoE,77
|
|
148
|
+
crimsonland-0.1.0.dev15.dist-info/METADATA,sha256=iyGs5qMssJ8szJDMfYv0jRbZnycT3rFPOTs_lI9B7nU,5262
|
|
149
|
+
crimsonland-0.1.0.dev15.dist-info/RECORD,,
|
grim/.DS_Store
ADDED
|
Binary file
|
|
File without changes
|