Stairs create unknown sector types

From DoomWiki.org

This problem can also occur in Strife.

All versions of the Doom engine contain an error in the EV_BuildStairs function which leads to garbage memory being loaded by various parts of the engine, causing unpredictable behavior. By leaving some data fields each stair sector uninitialized, other functions later read effectively random values out of these fields, potentially leading to crashing; if the value of the type field just happens to be donutChange (a specific number value), uninitialized memory will be propagated to the sector, potentially causing a crash if that sector is stood on or rendered. This crash is of extreme rarity due to the specific value required out of all 232 (or 4,294,967,296) possible values (though some values are more likely than others), and may not be witnessed even once during several years of gameplay.

A related consequence of the uninitialized values causes stair builders to be nearly guaranteed to deal crushing damage in vanilla Doom, due to the crushing properties byte causing crusher damage to be enabled with any value that is not 0. It happens with a high enough probability that Chocolate Doom, PrBoom, and other ports explicitly initialize the value to one that enables crushing damage.

In the video, a descending set of stairs in Tarnhill that lead to the Front Base have suffered this malfunction on a single stair step. It has taken on a yellow-and-black-striped texture, and has the unknown sector type 102. The game exits as soon as the player steps into this sector. Unfortunately, due to Strife's hub system, this error has been saved permanently into the player's save file and will continue to act as an obstruction to progress.

This bug was first discovered and repaired by authors of the Boom source port, but knowledge of it remained low even afterward.

Technical explanation[edit]

The data structure floormove_t is used to store information about a moving floor. It consists of nine fields:

p_spec.h

typedef struct {
    thinker_t thinker;
    floor_e   type;
    boolean   crush;
    sector_t* sector;
    int       direction;
    int       newspecial;
    short     texture;
    fixed_t   floordestheight;
    fixed_t   speed;
} floormove_t;

When a stair is being built, the EV_BuildStairs function is called to set up the thinkers that will move the steps. The following is the relevant logic, abbreviated and refactored for clarity:

p_floor.c, EV_BuildStairs

int EV_BuildStairs (line_t* line, stair_e type) {
    ...
    floormove_t* floor;
    while ((secnum = P_FindSectorFromLineTag(line, secnum)) >= 0) {
        sec = &sectors[secnum];
        if (sec->specialdata) continue; // Skip sectors that are already moving
        ...
        height = sec->floorheight + stairsize;
        texture = sec->floorpic;
        while(true) { // Make a thinker for each step
            floor = Z_Malloc(sizeof(*floor), PU_LEVSPEC, 0);
            sec->specialdata = floor;
            P_AddThinker(&floor->thinker);
            floor->thinker.function.acp1 = (actionf_p1) T_MoveFloor;
            floor->sector = sec;
            floor->direction = 1;
            floor->floordestheight = height;
            floor->speed = speed;
            
            // Find the next step...
            ...
            if (!found_next) break;
        }
    }
    ...
}

The function iterates through sectors with the matching tag, and tries to build a sequence of stair steps from each. This is done by creating a thinker to move the step's floor, and a floormove_t (the floor variable) to hold data that the step will use.

When space in memory is allocated (with Z_Malloc), the space is likely re-used from a previous allocation and still contains old memory present in that space. This is known as garbage memory, and is essentially random. For this reason, fields in floor that are going to be read from must first be written to/initialized, otherwise the game will read garbage memory and create unpredictable behaviour. The following fields are initialized:

  • floor->thinker
  • floor->sector
  • floor->direction
  • floor->floordestheight
  • floor->speed

The remaining fields are not initialized, and are filled with garbage memory:

  • floor->type
  • floor->crush
  • floor->newspecial
  • floor->texture

Crush garbage[edit]

The crush field is very likely to be of consequence, as every possible value except 0 causes the sector to deal crushing damage. It is unclear what the intended behavior is in this case. This can cause demo desync when a player or monster is damaged by a crushing stair, as recording a demo and then playing it back on the same engine immediately afterwards may have two different outcomes because of the undefined nature of garbage memory.

Source ports may handle this differently. For example, Chocolate Doom always initializes the crush field to a value that is neither 0 or 1, which consistently leads to crush damage:

p_floor.c, EV_BuildStairs (Chocolate Doom)

// e6y
// Uninitialized crush field will not be equal to 0 or 1 (true)
// with high probability. So, initialize it with any other value
floor->crush = STAIRS_UNINITIALIZED_CRUSH_FIELD_VALUE;

Donut garbage[edit]

The type field is only used by donuts, and the newspecial and texture fields are only of consequence if the type field is the donutRaise value. This is unlikely, as there are 232 possible values for a 32-bit field, though the garbage memory left behind is not truly random so not all values have equal probability. If it does happen, the thinker function that moves the floor will apply the garbage memory fields on the sector. The following is that thinker function, edited for brevity:

p_floor.c, T_MoveFloor

void T_MoveFloor(floormove_t* floor) {
    ...
    if (res == pastdest) { // Floor reached its destination and stopped
        ...
        if (floor->direction == 1 && floor->type == donutRaise) {
            floor->sector->special = floor->newspecial;
            floor->sector->floorpic = floor->texture;
        }
        ...
    }
}

If type happens to be donutRaise, the sector will have garbage memory written into its special field (the sector type, e.g. secret or damaging) and floorpic field (that determines the floor's flat texture). These two fields have each their consequences:

  • If the special field is of an unrecognised type when the player steps on the affected floor, the game stops with the error message "P_PlayerInSpecialSector: unknown special X", where "X" will be the number value of the garbage memory occupying the field.
  • If the floorpic field happens to be a number that does not correspond to any loaded flat when trying to render the offending floor, the game will stop with the error message "W_CacheLumpNum: X >= numlumps", "X" being the lump offset of the flat that was to be accessed.

The texture-related crash is less likely to happen, as the number of loaded flats is typically bigger than the number of valid specials/sector types.

Source ports may alter this behavior. For example, DSDA-Doom treats invalid special values the same as 0, and has a default flat texture as a stand-in when an invalid texture flat is requested, avoiding both crash conditions.

Gallery[edit]