Skip to content

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 maintains an internal RT sized from 1024/texture_scale.
  • GroundRenderer.schedule_generate(seed=...) queues terrain generation, and GroundRenderer.process_pending() performs the scheduled RT creation/generation work.
  • GroundRenderer.draw(camera_x, camera_y) draws the RT to the screen using UV scrolling.
  • texture_scale is treated as a terrain-setup input, not a live runtime knob. Existing menu/gameplay grounds keep the scale they were created with until terrain is explicitly replaced.

Intentional rewrite deviations:

  • Procedural terrain stamps keep bilinear sampling while rotating into the RT. The original engine appears to point-sample those stamps, but bilinear reads better in the port and still stays within current fixture tolerances.
  • Corpse atlas frames keep bilinear sampling while baking for the same reason.

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/render/test_ground_dump_fixtures.py

Run the test:

uv run pytest tests/render/test_ground_dump_fixtures.py

Notes:

  • Requires a display (raylib); the test skips if DISPLAY / WAYLAND_DISPLAY is 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_scale to positions/sizes so baked pixels match the exe’s scaled RT.
  • Runs through the terrain alpha-test shim, so low-alpha fringe texels are discarded before blending.
  • Intentional rewrite deviation: generic decal sprites keep bilinear sampling while baking. The original engine appears to point-sample them, but bilinear reads better in the port.

  • 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
  • Applies the exe’s small alignment tweaks (-0.5 shift and offset = terrain_scale/512) and rotation offset (rotation - pi/2).
  • Intentional rewrite deviation: corpse atlas frames keep bilinear sampling while baking. The original engine appears to point-sample them, but that looks worse in the port at modern output scales.

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:

result_alpha = src_alpha * src_alpha + dst_alpha * (1 - src_alpha)

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 emulate XRGB more directly by masking out alpha writes while stamping into the terrain RT:

rl.rl_color_mask(True, True, True, False)
rl.rl_set_blend_factors(rl.RL_SRC_ALPHA, rl.RL_ONE_MINUS_SRC_ALPHA, 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_SRC_ALPHA, rl.RL_ONE_MINUS_SRC_ALPHA, rl.RL_FUNC_ADD)
# ... stamp decals/strokes into the RT ...
rl.end_blend_mode()
rl.rl_color_mask(True, True, True, True)

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.

Why this mode:

  • It keeps the terrain RT alpha pinned to 255 through generation and baking, which matches the XRGB mental model directly.
  • It is simpler than carrying separate blend-factor branches for alternate alpha behaviors that we do not intend to ship.

Current status

  • Gameplay produces decal events through FxQueue / FxQueueRotated (projectile hits and creature deaths) in src/crimson/game_world.py.
  • GameWorld.update() bakes queued decals into the ground render target via bake_fx_queues(...); the result is then shown when GroundRenderer.draw(...) blits the RT to the screen.
  • The ground debug 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.