Things snap up to ledges when sectors move
From DoomWiki.org
When a sector moves, such as a lift activating or a door opening, nearby things intersecting a ledge may instantly snap up to rest up at the height of the ledge. This affects items and monsters alike and typically manifests innocuously as a visual error, carrying no significant effect on gameplay, however it can also make items impossible to reach and cause enemies to become stuck on top of ledges or in ceilings. This is caused by the collision implementation, the code that ensures things stay properly colliding with the world around moving sectors, working overzealously; by intuition, things on the ground should have their height updated by a moving floor only when they are touching that floor, however the implementation additionally updates things that do not touch that floor. This article describes all circumstances that violate this intuition.
The inverse of this can also happen to things that hang from ceilings, such as hanging victims. In this case, the thing can snap down to the intersecting lower ceiling. For simplicity, this article only mentions things on the floor, though the behaviour is perfectly symmetrical in that it also applies inversely to things on the ceiling.
This bug is extremely common in custom levels for vanilla Doom. It can be very easily enabled by just having a thing placed against a ledge and a lift nearby. A level author can always prevent this bug from happening by keeping things away from the walls of high ledges, though it is safe to have things intersect impassable or monster-blocking linedefs as these will never be considered as ledges to snap onto.
Factors[edit]
The following are the factors that enable thing snapping to happen as well as a variety of circumstances and combinations with other engine bugs that give it nuance.
Thing height when spawned may conflict with collision detection: When a thing is placed into the game world either through level load, save load, or as an item drop by an enemy, its height is set to that of the floor directly below its centre point. This completely ignores surrounding walls and as a result may conflict with the collision detection performed when things move, where surrounding walls are considered. Thing snapping only occurs to things in such a conflicting position.
All things in a sector's blockbox can snap up: Sectors have bounding boxes made of blockmap tiles, called blockboxes in the source code. When the sector moves, every thing in the blockbox is checked regardless of whether or not it touches the moving sector. For this reason things that are just in the vicinity of a moving sector may snap up, and this bug is especially pronounced in levels that have very spaced apart disjointed moving sectors as these segments of the same sector must be covered by one blockbox. This additionally makes the bug sensitive to the blockmap offset, making it possible to add or remove instances of this bug in various parts of the level by shifting the blockmap, which can be done in a typical level editor by adding linedefs in the southwest corner of the level. This is fixed in Boom such that only things intersecting the moving sector will update.
Things are updated regardless of moving sector touch or direction: A thing on the ground does not need to touch a moving floor surface to be updated by that moving sector, e.g. a thing may be on a middle height floor and overlap both a high ledge and the edge of a lift shaft. Both in vanilla Doom and in Boom, the motion of a lift far below in the shaft causes the thing to snap up to the high ledge. Additionally, direction does not matter; even if the lift is moving down, the thing snaps upwards.
Some levels rely on thing snapping to function properly: Since the circumstances that enable this bug are so common, it is easy for level authors to create a situation where they rely on it happening without realising it. An example of this is in MAP28 of Alien Vendetta, where a secret relies on the bug occurring in the extended blockmap area—as a result, in Boom, this bug fails to happen and the item is unobtainable.
Snapping can be prevented by monster-blocking or impassable lines: Snapping is prevented by either of these two linedef flags, which can more appropriately be called thing-blocking. All lines that the thing intersects with must be marked as thing-blocking to ensure that blockmap data order plays no role.
Snapping is sensitive to blockmap data order: This bug is sensitive to linedef ordering within the BLOCKMAP lump of a given level; when the linedefs within a blocklist are iterated to find ledges or walls for a thing to collide with, finding a blocking linedef (with the impassable or monster-blocking flags) will end the linedef-finding loop immediately, not continuing to find ledges for the thing to snap onto. If a suitable ledge to snap onto is found before that, however, the snap is made, and the loop continues to find higher potential ledges to continue snapping the thing up to. This means it is possible for a thing intersecting with a very high ledge, and a moderately high ledge, to only snap up to the moderately high ledge if a blocking linedef is found before the very high ledge. An example that offers contrast is in map E2M1 of 2002 A Doom Odyssey with thing 54 (a candle); in version three of the WAD, this candle snaps up, but it does not in the (mostly identical) tenth anniversary edition, due to the latter having prioritised blocking linedefs over ledge linedefs.
Blocked things snap backwards: When a blocking thing such as a monster, player, or solid decoration (like a tree) is intersecting a snapping thing, the snapping thing is instead snapped down to the floor at its center point, the same height it was placed at when initially spawned. While a sector is in motion nearby to potentially cause snapping, the player can walk into the space below a snapping thing to observe that it snaps back down, and walk out to make it snap back up. When the sector stops moving, the thing is no longer susceptible to snapping and remains at whichever height it was at when the sector movement stopped.
Things that are immune to the blockmap are immune to snapping: Snapping does not affect things that ignore the blockmap, i.e. those that hold the MF_NOBLOCKMAP flag in their mobj definition. There are three decorative thing types that, perhaps accidentally, have this flag: 79 (a pile of guts), 80 (a pool of blood), and 81 (a brain stem). Because these things completely ignore the blockmap, they can never experience this bug, though they can experience another bug of ignoring lifts moving from under them so they appear floating in the air or embedded inside the lift, as is seen in MAP03 of Overboard.
A thing merely touching a diagonal linedef may be intersecting: The implementation that determines whether or not a thing intersects a linedef when that thing only touches the linedef with its side or corners has the following behaviour: If the line is right-angled (spans exactly north-south or east-west), a thing cannot intersect it by merely touching it. However, if a thing corner merely touches a diagonal/slanted linedef, the thing is not considered intersecting if the linedef faces away from the thing, and it is considered intersecting if the linedef faces towards the thing. This is caused by initial bounding box checking excluding right-angled linedefs, followed by the application of the BURN principle by the source code function P_PointOnLineSide (see the function's notes here on the wiki for an explanation)—the result is that the thing in front of the linedef is considered to also be behind the linedef, i.e. it intersects the line. An example of this being an issue is in MAP22 of Back to Saturn X: Episode 1.
Technical explanation[edit]
Thing spawning is handled by the function P_SpawnMobj in p_mobj.c, which uses R_PointOnSide in r_main.c for determining which side of a node the thing is on, repeatedly until the subsector and associated floor under the thing's center point is found. The thing is placed at the height of that floor, with no regard for nearby ledges.
The moving sector mechanics are found in the source code file p_map.c. A moving sector has a bounding box (or blockbox) consisting of blockmap tiles such that the sector is fully enveloped, and while that sector is moving, the function P_ChangeSector will iterate through all these bounding box tiles and update all things within.
P_ChangeSector:
... 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); ...
The function PIT_ChangeSector is called with each thing in these tiles to manage crushing and moving. As part of this task, it calls P_ThingHeightClip which updates the thing's vertical position to match the height of the floor it is on.
P_ThingHeightClip:
... boolean onfloor; onfloor = (thing->z == thing->floorz); P_CheckPosition (thing, thing->x, thing->y); // what about stranding a monster partially off an edge? thing->floorz = tmfloorz; thing->ceilingz = tmceilingz; if (onfloor) { // walking monsters rise and fall with the floor thing->z = thing->floorz; } ...
A call to P_CheckPosition is made to ascertain that floor, which is where a thing intersecting a ledge will be detected. The ledge is placed in the variable tmfloorz which then becomes the new associated floor of that thing.
This floor-finding step before updating the height ensures that things can not clip through walls around a descending lift, however the same mechanic is also what causes things below ledges to snap upwards on and around moving sectors.
Of note is that blockmap tiles are rather large and can easily contain things that would never touch the sector, and single sectors consisting of unconnected parts as well as concave sectors still have only one bounding box, potentially making them redundantly update things in an enormous and irrelevant area. The blockmap is also not static, having an origin point on the southwest corner of the map. This means that adding map geometry in the southwest part of a map may remove or add instances of this bug elsewhere by shifting the origin point of the blockmap.