Terrain (rewrite)¶
This page describes how the Python + raylib rewrite models the classic game's
terrain pipeline (see also: docs/crimsonland-exe/terrain.md).
Mental model¶
- The world background is a single 1024×1024 “ground” texture.
- In the original exe, it is a render target that gets:
1) procedurally generated once (
terrain_generate) 2) incrementally updated by baking decals (blood/corpses/etc) into the same texture (fx_queue_render) 3) drawn to the screen as one fullscreen quad with UV scrolling based on camera offsets (terrain_render)
Where this lives in the rewrite¶
Implementation: src/grim/terrain_render.py
GroundRenderer.create_render_target()creates/resizes the RT (1024/texture_scale).GroundRenderer.generate(seed=...)stamps the 3 procedural layers into the RT.GroundRenderer.draw(camera_x, camera_y)draws the RT to the screen using UV scrolling.
Ground dump fixtures (parity test)¶
We captured ground render-target dumps via Frida and use the PNGs as fixtures to ensure the rewrite produces identical output for the same seed and terrain texture indices.
- Fixtures:
tests/fixtures/ground/ground_dump_*.png+tests/fixtures/ground/ground_dump_cases.json - Test:
tests/test_ground_dump_fixtures.py
Run the test:
Notes:
- Requires a display (raylib); the test skips if
DISPLAY/WAYLAND_DISPLAYis missing. - Requires game assets at
game_bins/crimsonland/1.9.93-gog/crimson.paq.
Decal baking (what was missing)¶
The exe’s “persistent gore” works because it is drawn into the ground render target before terrain is blitted to the backbuffer.
The rewrite exposes the same mechanism via two helpers:
GroundRenderer.bake_decals([...])for generic textured decals (blood, scorch, etc).- Applies
inv_scale = 1/texture_scaleto positions/sizes so baked pixels match the exe’s scaled RT. -
Uses point filtering while stamping (matches the exe’s
filter=1during baking). -
GroundRenderer.bake_corpse_decals(bodyset_texture, [...])for corpse sprites (bodyset 4×4 atlas frames). -
Implements the two-pass corpse baking:
- a “shadow/darken” pass using
ZERO / ONE_MINUS_SRC_ALPHA - a normal alpha blend color pass
- a “shadow/darken” pass using
-
Applies the exe’s small alignment tweaks (
-0.5shift andoffset = terrain_scale/512) and rotation offset (rotation - pi/2).
Terrain filter ("terrainFilter")¶
The exe optionally forces point sampling when blitting terrain to the screen if
terrainFilter == 2.0.
The rewrite mirrors this via GroundRenderer.terrain_filter:
terrain_filter == 2.0→ temporary point sampling for the terrain blit only.
Blend mode when drawing to screen¶
During terrain generation, stamps are drawn with alpha blending enabled
(SRC_ALPHA / ONE_MINUS_SRC_ALPHA). On an RGBA render target, this affects not
just RGB, but also the alpha channel:
In the original exe, the "ground" render target is typically created in an
XRGB format (no alpha), so this drift never matters. In the rewrite, the RT is
RGBA, so we ensure the ground RT alpha stays at 1.0 by preserving destination
alpha while stamping:
rl.rl_set_blend_factors_separate(
rl.RL_SRC_ALPHA, rl.RL_ONE_MINUS_SRC_ALPHA, # RGB
rl.RL_ZERO, rl.RL_ONE, # A (keep dst alpha)
rl.RL_FUNC_ADD, rl.RL_FUNC_ADD,
)
rl.begin_blend_mode(rl.BLEND_CUSTOM_SEPARATE)
# On some backends, re-apply factors after switching the mode.
rl.rl_set_blend_factors_separate(
rl.RL_SRC_ALPHA, rl.RL_ONE_MINUS_SRC_ALPHA, # RGB
rl.RL_ZERO, rl.RL_ONE, # A (keep dst alpha)
rl.RL_FUNC_ADD, rl.RL_FUNC_ADD,
)
# ... stamp decals/strokes into the RT ...
rl.end_blend_mode()
Additionally, when drawing the terrain RT to the screen, we use a custom blend mode that fully replaces pixels (ignoring source alpha):
rl.rl_set_blend_factors(rl.RL_ONE, rl.RL_ZERO, rl.RL_FUNC_ADD)
rl.begin_blend_mode(rl.BLEND_CUSTOM)
# On some backends, re-apply factors after switching the mode.
rl.rl_set_blend_factors(rl.RL_ONE, rl.RL_ZERO, rl.RL_FUNC_ADD)
# ... draw terrain quad ...
rl.end_blend_mode()
This ensures terrain is always drawn opaque, matching the original game's behavior.
Current status¶
- Gameplay produces decal events through
FxQueue/FxQueueRotated(projectile hits and creature deaths) insrc/crimson/game_world.py. GameWorld.update()bakes queued decals into the ground render target viabake_fx_queues(...); the result is then shown whenGroundRenderer.draw(...)blits the RT to the screen.- The
grounddebug view is still useful for manual stamping when validating blend/filter behavior.
Remaining gaps¶
- Validate effect selection, sizes, and tints against runtime captures for a wider set of weapons/bonuses.
- Expand decal producers beyond the current hit/death hooks as more gameplay effects are ported.