Things block floors and ceilings when they should not
From DoomWiki.org
Due to various bugs, floors and ceilings in motion that should be impossible to obstruct can still get blocked by actors that overlap any ceiling (where an actor is a mobj bearing the MF_SHOOTABLE flag, such as a player, monster, or barrel). The result of obstruction depends on the linedef type that caused the motion; either the actors that overlap ceilings get crushed, or they block the sector's movement. Engine bugs increase the amount of situations where actors overlap ceilings and lead to complex interactions, sometimes making simple thing placement in custom levels cause significant bugs.
Level designers for vanilla Doom can avoid the bulk of these problems by following these rules of thumb to avoid monsters overlapping ceilings:
- Avoid monster placement where the monster overlaps any ceiling, even if the monster is fast enough to walk out of this position or if the sector later moves to make the monster unstuck.
- Avoid monsters having access to teleporters that would lead into a ceiling too low for said monster to fit under, as this will likely result in the monster being stuck in the ceiling, which can obstruct nearby sectors. An example of such a problem is in the original release of MAP31: Pharaoh from TNT: Evilution.
- If monsters are placed in such a way that they touch or overlap a ledge (a ledge being the side of a sector with a floor higher than the floor that the monster stands on), this will likely result in the monster being placed on the ledge instead of on the floor below, potentially clipping the monster into the ceiling above. An example of a monster that blocks sector movement by standing on a ledge it barely touches can be found in E3M4: Torture Chambers from Doom the Way id Did.
In vanilla Doom, this bug is additionally sensitive to blockmap offsets, meaning that instances of this bug can be added or removed in various parts of the map by shifting the blockmap, which can be done in a typical level editor by adding linedefs to the south-west corner of the level.
Some of the bugs contributing to this behaviour are fixed in Boom and its derived source ports.
Contents
Issues with movement directions that should be unobstructable[edit]
Sectors are capable of moving up or down in two different ways. These movement types are:
- Gradual movement, consisting of descending and ascending floors/ceilings that move some amount of map units per tic until the moving sector either has reached its target height precisely or would need to perform a sector snap to reach its target height.
- Sector snapping, which instantly sets the sector height to the target height, which then stops further movement. A sector will snap to its target height when a gradual move would overshoot the target height when moving up, or undershoot it when moving down. (This mechanic can be used by level designers to make lowered sectors instantly snap up to put monsters in the path of the player, as the sector tries to move down, which would undershoot its target height, so it gets snapped up to its target height instead.)
Intuitively, regardless of whether the movement is snapping or gradual, a floor moving down should not be blockable (because nothing can be below a floor) and neither should a ceiling moving up (as nothing can be above a ceiling).
However, both types of movement can be affected by bugs:
- Descending floors are affected by blocking actors: Descending floors are mistakenly programmed to be blocked if they have actors on them that overlap a ceiling. This check is of course completely unnecessary since the floor is moving away from the ceiling and any potential obstruction. Ceilings that ascend lack any such code and are correctly incapable of being obstructed. The offending code is removed in Boom, fixing this bug. An example of a blocked descending floor can be found in the first release of MAP32: Dad Bod from Arrival, where monsters placed on a descending floor trap are initially stuck in the ceiling, preventing the floor from moving.
- Sectors snapping in place are always affected by blocking actors: Sector snapping is programmed separately from descending/ascending floors and ceilings, and all snapping is vulnerable to obstruction regardless of whether or not it makes sense given the sector's movement direction. As a result of this, a gradually ascending ceiling—which correctly does not check for obstruction—can eventually require a sector snap to align with its target height, which can be blocked by an obstruction, such as an actor inside the sector despite the fact that the ceiling is moving away from the actor. For example, if there is a gradually ascending ceiling with height 10 and speed 2 that intends to reach a target height of 15, it will be impossible to obstruct while it gradually ascends between heights 12 and 14, but when it attempts to snap to height 15, it gets stuck if an obstruction is detected since the snapping code does not check which direction the ceiling is moving.
Other causes and interactions[edit]
- Finding obstructions uses imprecise blockmap checks: A sector in motion predicts if an actor overlaps a ceiling after sector movement. This is to detect crushing, so if this happens, depending on the linedef type the sector may stop or crush. This prediction is performed by checking if any nearby actors overlap a ceiling after the sector movement. Checking nearby actors is done with the blockmap; each sector has a blockbox (a bounding box of blockmap tiles), wherein all actors are checked while the sector moves. A significant consequence of the simplicity of this check is that actors that do not even touch the moving sector can obstruct the sector, merely needing to be standing nearby and overlap any ceiling. This is the interaction that makes the behaviour sensitive to blockmap offsets—in Boom, this problem is fixed by requiring potential obstructions to overlap the moving sector.
- Actors need not touch the moving part to obstruct it: When a sector moves and determines if actors would get stuck in a ceiling after sector movement, it does not determine specifically if its own moving floor/ceiling is being touched. As an example of where this is an issue, an actor may stand on the edge between sector A and sector B, where sector A is unmoving and has a low ceiling that the actor is stuck in. Sector B has a descending ceiling very high up. This setup causes the very high up descending ceiling to be blocked, even though the actor is never in contact with it.
- Actors can snap up to ledges to become stuck in ceilings and block sectors: An actor that has overlap with a ledge (i.e. the side of a sector whose floor is higher than the floor the actor stands on) is vulnerable to thing snapping. Thing snapping occurs when a moving sector checks it, which also updates the actor's height such that it gets placed at the height of the highest ledge it overlaps (barring some circumstances detailed in the dedicated article for thing snapping). This can result in the actor becoming stuck in a ceiling if there is not enough height clearance above the ledge, potentially obstructing sectors.
- Actors overlapping a ceiling and a thing may not be blocking: If an actor overlaps a ceiling such that the ceiling is not overlapping the actor's center point, and the actor overlaps a blocking thing, then the actor is not considered stuck in the ceiling, making it unable to obstruct any sectors. An example of this strange behaviour is in MAP07 of Obituary.
- Actors overlapping a ceiling and a blocking line may not be blocking: Similarly, if an actor overlaps a ceiling away from the actor's center point, and a monster-blocking or generally blocking linedef, it is not considered to be stuck in the ceiling if the monster-blocking line is checked first for collision. This makes the outcome dependent on linedef ordering inside the blockmap which depends on the level editing tool. An example of this happening due to prioritised blocking linedefs is in MAP04 of Scientist 2.
- Sector movement can remove actors from ceiling overlap positions: Since the moving sector checks for actors overlapping ceilings by predicting whether or not they would do so after it moves for once tic, the sector will not be blocked if the actors overlapping ceilings no longer overlap after the move. As an example, an actor standing on sector A overlapping the ceiling by 1 unit will block nearby moving sectors B, C, etc, but if sector A tries to move down a unit, A predicts the actor then has a lower position and as a result is below the ceiling, so there would be no overlap, making the sector move successfully—and unblocking B, C, etc in the process. Instantly moving sectors are capable of removing actors stuck more deeply in the ceiling, as they can move any distance in a single tic.
Technical explanation[edit]
This issue has several contributing factors with different origin points. The following are explanations of those factors separated by origin point.
Erroneously placed blocking checks[edit]
The function T_MovePlane holds the logic for changing floor and ceiling heights, and is directly responsible for the bugs regarding movement directions that should be unobstructable. The function is split into floor and ceiling behavior, each branch being split into upwards and downwards logic, and each of those branches are split into instant and gradual motion. The floor branch is as follows, modified for brevity and clarity.
T_MovePlane, p_floor.c:
if (floor_direction == DOWN) {
if (sector->floorheight - speed < dest) { // SNAP TO TARGET HEIGHT
lastpos = sector->floorheight;
sector->floorheight = dest;
something_stuck = P_ChangeSector(sector,crush);
if (something_stuck) {...} // Then undo height change
} else { // GRADUAL MOVE
lastpos = sector->floorheight;
sector->floorheight -= speed;
something_stuck = P_ChangeSector(sector,crush);
if (something_stuck) {...} // Then undo height change
}
} else { // floor_direction == UP
if (sector->floorheight + speed > dest) { // SNAP TO TARGET HEIGHT
lastpos = sector->floorheight;
sector->floorheight = dest;
something_stuck = P_ChangeSector(sector,crush);
if (something_stuck) {...} // Then undo height change
} else { // GRADUAL MOVE, COULD GET CRUSHED
lastpos = sector->floorheight;
sector->floorheight += speed;
something_stuck = P_ChangeSector(sector,crush);
if (something_stuck && crush) return crushed;
if (something_stuck) {...} // Then undo height change
}
}
The above logic for floor movement has a stuck-checking condition in every situation, even though it does not make sense in the case of floors moving down; the code path where the floor moves down and moves gradually has a stuck check, which is an error as per the intuition that floors moving down cannot have anything below them.
Similarly, floors moving down and instantly snapping to the target height also have a stuck check that intuitively should not be there, however this is complicated by this code path being capable of also moving the floor upwards; if the floor is initially below its target height, then moving down would undershoot its target height, so it snaps to the target height, apparently moving up. For this reason, refactoring the code or adding more checks would be necessary to avoid erroneous stuck checking for sector snapping.
In Boom, the stuck check for gradually descending floors is deleted, avoiding them getting pointlessly stuck. No changes were made to snapping sectors, however.
For contrast, the logic for ceilings with similar alterations to the code is as follows.
if (ceiling_direction == DOWN) {
if (sector->ceilingheight - speed < dest) { // INSTANT MOVE
lastpos = sector->ceilingheight;
sector->ceilingheight = dest;
something_stuck = P_ChangeSector(sector,crush);
if (something_stuck) {...} // // Then undo height change
} else { // GRADUAL MOVE, COULD GET CRUSHED
lastpos = sector->ceilingheight;
sector->ceilingheight -= speed;
something_stuck = P_ChangeSector(sector,crush);
if (something_stuck && crush) return crushed;
if (something_stuck) {...} // // Then undo height change
}
} else { // ceiling_direction == UP
if (sector->ceilingheight + speed > dest) { // INSTANT MOVE
lastpos = sector->ceilingheight;
sector->ceilingheight = dest;
something_stuck = P_ChangeSector(sector,crush);
if (something_stuck) {...} // // Then undo height change
} else { // GRADUAL MOVE
lastpos = sector->ceilingheight;
sector->ceilingheight += speed;
P_ChangeSector(sector,crush);
}
}
Ceilings gradually moving up lack the stuck check completely, making them incapable of being stuck which is correct as per the intuition that ceilings cannot have anything above them. It remains a possibility that actors can obstruct sectors snapping in any direction, however.
Blockmap overreach[edit]
As seen in the code snippets for the erroneously placed blocking checks, the function P_ChangeSector is used to check if the sector has something_stuck, i.e. if the sector movement is obstructed by an actor. Here are relevant parts of that function, which iterate through all tiles of the sector's blockbox (the bounding box of blockmap tiles that the sector has around it) and checks all things in each tile.
P_ChangeSector, p_map.c:
...
nofit = false;
for (x = sector->blockbox[BOXLEFT]; x <= sector->blockbox[BOXRIGHT]; x++) {
for (y = sector->blockbox[BOXBOTTOM]; y <= sector->blockbox[BOXTOP]; y++) {
P_BlockThingsIterator (x, y, PIT_ChangeSector);
}
}
return nofit;
This function has a global variable nofit that gets set to false initially, and is set to true if an actor is stuck in a ceiling. The code pointer PIT_ChangeSector determines whether or not a given actor overlaps a ceiling with no considerations of whether or not the actor overlaps the moving sector or touches the moving part, meaning that the blockbox code above is the only filtering that occurs with regard to the moving sector. This lack of additional filtering is what leads to actors standing nearby without touching the moving sector to also be potentially obstructing.
Actors overlapping a ceiling and something else failing to obstruct[edit]
The function PIT_ChangeSector as discussed in the technical explanation for blockmap overreach performs a variety of duties, including checking for actors stuck in ceilings via a call to the function P_ThingHeightClip. This function calls P_CheckPosition to find the following two important variables for a given actor:
- The highest floor of sectors that the actor overlaps (tmfloorz)
- The lowest ceiling of sectors that the actor overlaps (tmceilingz)
These variables are useful because of the following assumption: an actor stands on the highest floor of the sectors it overlaps. Therefore, if the difference between the highest floor and the lowest ceiling of this actor is less than the actor's height, the actor overlaps the ceiling.
P_ThingHeightClip, p_map.c:
...
P_CheckPosition(thing, thing->x, thing->y);
thing->floorz = tmfloorz;
thing->ceilingz = tmceilingz;
...
if (thing->ceilingz - thing->floorz < thing->height) {
return false;
}
return true;
Inside P_CheckPosition, these variables are set by first initialising them to be the floor and ceiling heights of the sector at the actor's origin, and then iterating through the linedefs that the actor overlaps to find overlapping sectors, so that if a given overlapped sector e.g. has a floor height higher than currently held in the highest floor variable, the variable is overwritten with this new highest floor height. This continues until the height of the lowest floor and similarly the height of the highest ceiling out of all overlapped sectors are found.
The problem arises from the fact that the function P_CheckPosition has more features built into it, so that it does more than finding the above variables; it also returns a boolean indicating whether or not this actor is stuck, which is irrelevant information for this current purpose as P_ThingHeightClip discards the return value. This other feature is capable of interfering with finding the variables by making the function end before all floors and ceilings around the actor are checked.
P_CheckPosition, p_map.c:
...
for (bx = xl; bx <= xh; bx++) {
for (by = yl; by <= yh; by++) {
if (!P_BlockThingsIterator(bx, by, PIT_CheckThing)) {
return false;
}
}
}
...
for (bx = xl; bx <= xh; bx++) {
for (by = yl; by <= yh; by++) {
if (!P_BlockLinesIterator(bx, by, PIT_CheckLine)) {
return false;
}
}
}
...
This function first checks for blocking things that overlap this actor to make it stuck, and returns immediately if one is found, and it also checks for whether or not this actor overlaps a blocking linedef, and returns immediately if one is found. This creates two classes of wrong behaviour:
- If there is a blocking thing overlapping the actor, this function immediately returns without checking any linedefs, meaning that the highest floor/lowest ceiling variables that the ceiling overlap checker has to work with are the initial values, those of the sector at the actor's origin.
- If there is an overlapping linedef that can block the actor (specifically it has flags that make it block the actor or it has no backside) then it causes the function to return immediately, halting further line checking. This is sensitive to data ordering inside the blockmap, as blocking linedefs iterated first can prevent later linedefs from being checked.
If either class of wrong behaviour happens, the height variables may be set to the wrong values, which gives the ceiling overlap checker bad information, which can make the moving floor fail to detect that the actor overlaps the ceiling, making the sector not be obstructed when it otherwise would be.
