Bugs in No One Lives Forever (GOTY Edition, version 1.003) as it shipped, found while reconstructing it. Each is a mistake in the code that a source change would fix.
The release build reproduces every one of them, on purpose: compiling to the
retail machine code means compiling its mistakes too. They are fixed in the
modern build (make modern, the NOLFSRC_MODERN define; see
BUILDING.md, "Variant builds"): each fix goes behind #ifdef NOLFSRC_MODERN, with the retail code kept in the #else, so the release
build is unchanged and still matches. This is step 3 of the README's roadmap.
An entry that is fixed says so under its heading, with the file the fix is in.
Fixed so far (in the modern build):
CSoundMgr::GetFilterIndex: the name comparison is inverted- The hit-sphere debug view loads a model that does not exist
- The ammo HUD surface is sized for a 10000-shot clip
GetMovementDatareads one past its table, and the word there is a NaN- Doors and switches with no move direction normalise a zero vector
FACEOBJECTnormalises a zero vectorCCharacterFX::InitLocalPlayerindexes the style table unchecked- Player setup copies the skin and head settings into a 32-byte buffer
- Player names and chat are used as printf format strings
- The server browser indexes an option table with a value from the network
ar_FindFilecloses a find handle it never openedci_GetRenderModesnever closes its find handlestreamsim_Openleaks the file when the stream cannot be allocatedInitLevel's trace misspells a parameterCLTDirectMusicMgr::InitLevelleaks the path it built when the control file will not openCMemStream::Read's debug line reads a missing argumentci_SetLightRadiusdoes not check its object handleci_GetSurfaceDimswrites through unchecked pointerssm_WriteObjectFilesdereferences the first file identifier unchecked- Error messages that name the wrong function
CSoundMgr::GetReverbProperties: damping is guarded by the wrong flagCObjBase::InitFieldsleavesm_StandingLinkuninitialised- Level particle systems stop emitting at high frame rates
- Above 100 frames a second the game world runs fast
at_Advancedivides by the last keyframe's time- Model shadows are switched off for good by the first cinematic
StopSecondaryandStopMotiftest the wrong object when given a nameci_GetModelNodeNamerefuses node 0 and does not terminatese_ModelHashCleanupuses a global instead of its argument, and leaksws_AddClippedContactdivides by a dot product that can be zero- The property-type guard normalises to a value that is also out of range
- The tree-path builder ignores the buffer length it is given
- The object-DLL temp name: one copy unterminated, two unbounded
CBaseStream::ReadStringnever reports a short readCBaseStream::WriteStream: the source offset is ignoredCString::Concat(LPCTSTR): appending leaves the length shortModel::LoadChildModelsreports success when it cannot size one tableServerMessage::ReadDataandWriteDatabound-check a truncated size- The fragment-record allocator passes a null to the list splice
- Checks reported at the wrong debug level
- The value-returning readers quote the shutdown message
- The compressed-vector codec does not round-trip
- The box-intersect test reports the wrong plane on three of six faces
LTMatrix::TransformPlanenever transforms the point it dotsw_IsPolyBlockinghas no bounds checkswm_ResetGeometry: wrong block centres, unguarded divisions, and a missing null checkServerMessage::WriteDataclips the count, then grows the buffer anyway- A server's join password is sent to anyone who asks
- The dedicated server's console prints text as a format
- The dedicated server's console line can lose its terminator
- The dedicated server fills the game's option table unbounded
-nowizis not seen as the last argument-NetLeveland-NetCALevelset settings nothing reads- The server's setup wizard never closes its find handle
- Stepping up onto a slope where it is not
Every entry names the retail address it was read from, the binary, and the
file that reproduces it. An entry is listed only once its function is
byte-identical, or a known near match, so it describes what the retail code
does, not a guess. Addresses are as in the retail images
(build/reference.sh extracts them into out/reference/). The game DLLs
(CShell.dll, Object.lto) are built from Monolith's own source as it
shipped, so their entries name the function and the source file instead.
CParticleSystemFX::Update (CShell.dll) · src/game/ClientShellDLL/ParticleSystemFX.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): an update that owes less than one particle returns without restarting the clock, so the time adds up until one is owed.
Each update adds floor(fParticlesPerSecond * dt) particles, dt being
the time since the last update (capped at 1/15 s), and then restarts the
clock. When a frame is shorter than one particle's worth of time the
count rounds down to 0 every frame, and the emitter never emits: at 100
frames a second, every system slower than 100 particles a second goes
dark. M06S03's ambient underwater particles were never there with a fast
renderer.
CServerMgr::Update (lithtech.exe, server.dll) · src/engine/server/smgr.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, MODERN.md M-P1-23): a tick runs only once 10 ms have passed since the last one.
In single player the client runs the server once a frame, and each tick
takes the real time since the previous call, clamped to between 10 and 200
ms, as its step: LTCLAMP(m_FrameTime, 0.01f, 0.2f). A frame shorter than
10 ms still advances the world by 10 ms, so above 100 frames a second game
time runs ahead of real time -- measured at 1.6 times at 159 frames a second
-- and with it everything on the server: AI, doors, moving platforms, the
cut-scenes' cameras and the timers that end their lines of dialogue. A
machine of 2000 never got near it; a fast renderer today does.
The animations are off at high frame rates too, the other way: client and
server advance them by (int)(frame time * 1000) milliseconds and drop
the rest, up to a millisecond a frame (cs_UpdateAnimations,
clientmgr2.cpp, and the sprite updates in csvis.cpp;
sm_UpdateObjectTrackers, servermodel.cpp), which at 300 frames a second
is a sixth of the time.
ws_StairStep (lithtech.exe 0x0041a0e0, and server.dll) · src/engine/server/worldsim.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, MODERN.md M-P5-20): a floor or a slope one can walk on lifts the feet straight up onto the highest point of it that is under them.
Before a player's move, the engine looks for floors the feet are moving into and steps the move up onto them. It finds them by walking the world's tree, which finds a polygon by its plane, and lifts the move by how far the swept box's lowest corner is below that plane, along the plane's normal. It never asks whether that corner is over the polygon, and the plane goes on past the polygon's edges. Where the ground beyond an edge is lower than the slope carried on -- the top of a ramp, a ridge between two slopes of terrain -- a corner out there is below the plane, so the player is stepped up onto the slope in mid-air. The lift along the normal is backwards, too, off the slope, so the next move does not meet it: the player falls, gravity adds up, and the next lift is bigger. Measured in the release build at a ramp in M01S03 (a slope of 1 in 2, its top edge at y 53.8, the floor beyond at 53): shoves of 6 to 16 units, lifting the player as high as y 72, and 44 frames in the air; walking a ridge up a hillside in M01S04, lifts of 4 to 9 units every few frames and 27 shoves back, the player in the air most of the way. A stutter at the top of a ramp and up some terrain, at any frame rate.
CWeatherFX::CreateSnow (CShell.dll) · src/game/ClientShellDLL/WeatherFX.cpp
CreateSnow reads the flake rate for the volume's snow level
(SnowNumLightFlakes 100, SnowNumNormalFlakes 300 or SnowNumHeavyFlakes
700 a second, CLIENTBUTES.TXT [Weather]), scales it by the volume's size
against a 1000-unit cube into fFlakesPerSec, and then never uses it: the
particle system is created with the rate read from the attributes a second
time. Every snow volume snows at the same rate, however big it is. Rain
(CreateRain) uses its scaled rate, so a rain volume's density does follow
its size.
Not fixed: the snowy levels were made and tuned with it, and their volumes are far from the 1000-unit cube the scaling assumes. Measured in the game, M12S04's eight outdoor volumes are 45 to 548 times that cube, so with the scaling every one would hit the particle system's cap of 5000 flakes a second: about 250,000 flakes alive at once where the retail game has 2,000 to 3,000, a blizzard where Monolith placed light snow. The small volumes in M13S01, M13S02 and M14S02, 0.003 to 0.06 times the cube, would fall to a flake a second or less. Snow as the retail game draws it is what the levels were designed to look like.
0x100016be (server.dll) · src/engine/server/counter.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): an object DLL that declares a property type outsidePT_STRING..PT_ROTATIONis refused as an invalid object DLL (src/engine/server/counter.cpp). The release build keeps the rewrite to 8.
Walking a freshly loaded object DLL's class table, every PropDef::m_PropType
outside PT_STRING..PT_ROTATION is rewritten to 8, which is one past
PT_ROTATION and so not a valid type either. A level with a corrupt property
type is silently given a type nothing can interpret, rather than being
refused.
0x1000706a (server.dll) · src/engine/server/filemgr.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN):src/engine/server/filemgr.cppcloses the handle only when_findfirstopened one. The release build keeps the retail close.
_findclose is called with the handle even when _findfirst returned -1:
the close sits after the if, not inside it.
0x00482fd0 (lithtech.exe) · src/engine/lithtech/clientsound.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN):src/engine/lithtech/clientsound.cpptestsstricmp(...) == 0. The release build keeps the retail test.
The function answers "which index is the filter called this?" by walking Miles' filter enumeration, and the comparison is the wrong way round:
if (stricmp(pFilter, pName))
return LT_OK; /* names DIFFER -> report found */
(*pIndex)++; /* names MATCH -> keep walking */So the first filter whose name is not the one asked for is reported as
found, and a name that matches makes the walk carry on past it.
CSoundMgr::GetFilterParamName (0x004830d0) does the same search and gets
it right; this one is missing == 0.
0x00482230 (lithtech.exe) · src/engine/lithtech/clientsound.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN):src/engine/lithtech/clientsound.cpptestsREVERBPARAM_DAMPING(0x10) for the damping. The release build keeps the retail test.
ReverbProperties::m_dwParams says which fields the caller wants. The getter
tests bit 1 for the volume, 2 for the room type, 4 for the reflection time,
8 for the decay time, and 8 again for the damping, which is bit 0x10's.
SetReverbProperties (0x00482010) uses 0x10, so a caller can set the
damping but cannot read it back unless it also asks for the decay time.
MFCStub.lib (also in ButeMgrNoMFC.lib) · src/lithshared/MFCStub/mfcs_string.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): the length is set after the copy (src/lithshared/MFCStub/mfcs_string.cpp, the copy the game links). The release build keeps the retail code.
It expands the buffer, copies the new characters in and returns, without
calling SetLength. The stored length stays what ExpandBuffer carried over,
and GetLength() under-reports the string from then on.
0x10011402 (server.dll) · src/engine/server/basestream.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): a start the caller gives is sought to before the copy; the default still copies from the source's current position, as every caller expects (src/engine/server/basestream.cpp). The release build keeps the retail code.
The function copies a range out of another stream, starting at dwMin.
dwMin is used to work out how many bytes to copy and for nothing else: the
body never seeks the source stream (no call through its SeekTo slot), so
the bytes are read from wherever the source happened to be. Every caller in
the game passes WRITESTREAM_DEFAULT, which is why it never shows.
0x10012d4e writer, 0x10014830 reader (server.dll) ·
src/engine/server/servermsg.cpp, src/engine/server/lmessage.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): the reader divides by 2^18 - 1, the writer's own scale (src/engine/server/lmessage.cpp). Only the reader changes, so a vector from a retail writer decodes exactly too, and what the modern writer sends a retail reader is unchanged. The release build keeps the retail constant.
The writer scales each minor-axis fraction by 262143.0 (2^18 - 1); the
reader multiplies by 3.814697265625e-06, which is 2^-18 (1/262144). They
are not reciprocals, so every decoded vector comes back short by one part in
262144 on both minor axes.
0x1000a411 (server.dll) · src/engine/server/worldmodels.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): a block with no vertices keeps a zero centre instead of dividing by zero, and the polygon half skips a null polygon or one with no vertices (src/engine/server/worldmodels.cpp). The block centre still weights a shared vertex once per polygon: that only moves the centre, and the radius pass reaches every vertex, so the sphere is still a bound. The release build keeps the retail code.
Three faults in one function:
- A block's centre is the sum of its vertices divided by a count that is summed over the block's polygons, not over its distinct vertices. A vertex shared by two polygons is added once and counted twice, so the centre is pulled toward the most-shared corners.
- That division (
0x1000a4ae) is never checked. The loop above it skips null and vertex-less polygons, so a block with none that count divides 0 by 0; the radius loop then compares against a NaN, and the block ends up with a garbage centre and a radius of 5.0. The per-polygon division bym_nVertices(0x1000a5bd) is unguarded too. - The block half checks each
m_ppPoliesentry for null (0x1000a4df); the polygon half dereferences the same array (0x1000a562) without checking.
0x1000f1f7 (server.dll) · src/engine/server/objbank.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): the three positive faces take their distance from the box's maximum (src/engine/server/objbank.cpp). The release build keeps the retail planes.
w_IsectObjectShape builds all six faces of the object's bounding box. The
three negative faces are right: the normal points along -axis and the distance
is the box's minimum, negated. The three positive faces set the normal to +1
along the axis and then take the distance from the minimum as well:
1000f358: fld1 ; +1.0f, into the normal
1000f366: mov 0xf0(%ecx),%ecx ; m_BBoxMin.x, not m_BBoxMax.x
1000f640: mov %ecx,0xc(%eax) ; m_Dist = min.x
(0xf4 on the +Y face and 0xf8 on +Z are the same slip.) A hit on the far
side of any object comes back with a plane through the near face, displaced
by the width of the box, although the intersection point itself is right.
Anything that reflects or slides along the reported plane gets a surface in
the wrong place.
0x1000f74e (server.dll) · src/engine/server/objbank.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): the point on the plane goes through the object's transform before the dot product, as in the SDK'sltmatrix.h(src/engine/server/objbank.cpp). The release build keeps the retail code.
The plane transform inlined into w_IsectObjectGeometry is
LTMatrix::TransformPlane from a version of ltmatrix.h that is one line
shorter than the SDK's (src/LT2/sdk/inc/ltmatrix.h):
startPt = thePlane.m_Normal * thePlane.m_Dist;
MatVMul_3x3(&ret.m_Normal, this, &thePlane.m_Normal);
MatVMul_InPlace_H(this, &startPt); // <-- missing in the retail code
ret.m_Dist = ret.m_Normal.Dot(startPt);
So the new distance is the transformed normal dotted with a point still in the object's own space: the reported plane is right only when the transform has no translation. (The source writes the shorter version out in place, since calling the header would emit the missing line.)
0x1001c604 (server.dll) · src/engine/server/commonmodel.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): the failure goes to the shared exit with the recordedLT_ERROR, like every other (src/engine/server/commonmodel.cpp). The release build keeps the retail jump.
Every failure in the function goes to the exit that returns the LT_ERROR
recorded on entry, except one: when sizing the table at +0x8c fails, it
jumps to the success return instead:
1001c6c3: call 0x10019f29 ; SetSize2(...)
1001c6c8: test %eax,%eax
1001c6ca: je 0x1001cb25 ; 0x1001cb25 is `xor %eax,%eax`
A model whose table cannot be allocated loads as LT_OK with none of its
pieces, nodes, child models or animations read.
0x1002616a (server.dll) · src/engine/server/servermsg.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN):src/engine/server/servermsg.cppkeeps the clip (it is the packet-size cap) and reports the lost tail toOutputDebugString, the first eight times, so a message that outgrows a packet shows. The release build keeps the silent clip.
The byte count is clipped against m_MaxDataLen first, and the loop after it
grows the allocation until the write fits. A write longer than m_MaxDataLen
silently loses its tail, while the buffer is enlarged to a size that would
have held it.
The clip is the packet-size cap doing its job; the fault is that the loss is
silent. WriteData returns nothing, so telling the caller would mean
changing the writer's interface and every caller: the fix reports it
instead.
0x10011314 (server.dll) · src/engine/server/basestream.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): a length that cannot be read is returned as the error it is (src/engine/server/basestream.cpp). The release build keeps the retail code.
The string is read in two parts, a two-byte length and then the body, and
only the body read's result is returned. A stream that runs out between the
two returns LT_OK with an empty string. (A caller that checks
ErrorStatus afterwards, as the SDK advises, still finds out.)
Object.lto · src/game/ObjectDLL/CharacterHitBox.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, MODERN.md M-P2-11): the same file loads364.abcand scales it by twice the radius. The release build keeps the retail code.
CCharacterHitBox::CreateNodeRadiusModels, the debug view that shows each
model node's hit sphere, creates its spheres from
SFX\Expl\Models\264.abc; no archive has that file (the sphere is
364.abc), so the view shows nothing. It also scales the sphere by the
radius, where the model needs twice that to match the hit sphere. Both found
by NOLF Modernizer.
0x00440480 (lithtech.exe) · src/engine/lithtech/clientmusic.cpp
Stop(LTDMEnactNextSegment) means "stop when the current segment ends". The
enact type is honoured only for the primary segment, which is left to run out;
the pending queue is emptied at once, and the secondary segments and motifs
are stopped immediately, whatever was asked.
Not fixed: honouring the request means queueing a stop to run when the
segment ends, which the manager has no request type for, and the game only
ever calls Stop with the default enact type (CMusic::Stop).
0x00440ae0, 0x00440dc0 (lithtech.exe) · src/engine/lithtech/clientmusic.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): one walk serves both the by-name and the stop-everything case: each node is visited once, its next link read first, and only a node with a segment state is stopped, so no node can be found forever (dm_StopPlayingNodes).
Stopping everything asks each node pNode->m_pSegState (is it playing?);
stopping one by name asks pNode->m_pSegment->m_pDMSegment (is it loaded?)
instead. The by-name loop re-runs FindByName until nothing is found and
StopNode does nothing to a node that is not playing, so a node that is
loaded but not playing would be found forever: a hang. Nothing reaches that
state today (a node is only added once DirectMusic has returned a segment
state), so the wrong test is the only thing between the loop and the hang.
CGameClientShell::TurnOnAlternativeCamera, TurnOffAlternativeCamera (CShell.dll) · src/game/ClientShellDLL/GameClientShell.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, M-P5-14):TurnOffAlternativeCameraandFirstUpdatesetMaxModelShadowsfrom the player'sDrawShadowssetting (src/modern/cshell/p5_gameplay.cpp,P5_RESTORE_MODEL_SHADOWS). This bug had been hiding a crash in our reconstructedd3d.ren's shadow pass (Direct3D'sDrawPrimitivedeclared without__stdcall), now fixed too.
Cinematics turn model shadows off: TurnOnAlternativeCamera saves
MaxModelShadows in g_nCinSaveModelShadows, sets it to 0, and
TurnOffAlternativeCamera writes the saved value back. But
UpdateAlternativeCamera calls TurnOnAlternativeCamera on every frame a
cinematic camera is live, so from the second frame on the value it saves is
its own 0, and the end of the cinematic restores 0. TurnOffAlternativeCamera
is also called by ExitLevel and LoadGame whether or not a cinematic ran,
and then writes the global's initial 0. Either way the variable is written
with +, so the 0 goes into autoexec.cfg: shadows stay off, in this and
later sessions, until the player sets them again in the display options.
CPlayerStats::UpdateAmmoSurfaces (CShell.dll) · src/game/ClientShellDLL/PlayerStats.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, M-P5-10): the surface is never made wider than the screen, and one that is gets made again (P5_FitAmmoHUD,src/modern/cshell/p5_gameplay.cpp).
The ammo bar's surface is made as wide as a full clip of ammo icons
(nShotsPerClip * m_AmmoSz.x) and only ever grows. The camera disabler's
ShotsPerClip in ATTRIBUTES/WEAPONS.txt is 10000, so selecting it once
creates a surface ten thousand icons wide, which every later weapon then
uses (cleared, drawn into and blitted, mostly off the screen's left edge)
until the HUD is rebuilt.
CPopupText::OnKeyDown, CInterfaceMgr::OnCommandOn (CShell.dll) · src/game/ClientShellDLL/PopupText.cpp, InterfaceMgr.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, M-P5-17): the key bound to Activate and the right mouse button close a popup too (CInterfaceMgr::OnKeyDown,OnRButtonDown).
A popup, such as the text of a picked-up intelligence item, is meant to close
with Activate as well as Escape: OnCommandOn closes it on
COMMAND_ID_ACTIVATE, and OnKeyDown has the test for the Activate key
commented out (if (key == VK_ESCAPE)// || key == m_nKey)). But showing a
popup pauses the game, which turns the input off, so the Activate command
never arrives, and Escape is the only way out.
0x100f4bb0 (Object.lto) · src/game/ObjectDLL/MyGameSpyMgr.cpp;
0x1001cc90 (CShell.dll) · src/game/ClientShellDLL/FolderJoin.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, MODERN.md M-P6-10): a locked server answers queries with\PasswordRequired\1instead of the password (MyGameSpyMgr.cpp) and checks the password itself: the client appends it, masked, to its handshake reply (GameClientShell.cpp), and the server boots a client whose reply lacks it or carries the wrong one (GameServerShell.cpp). The join folders ask for it (the LAN one gains a Password field; it never asked), and+connecttakes it fromNetJoinPassword. The release build keeps the retail behaviour.
CMyGameSpyMgr::OnRulesQuery answers every GameSpy \rules\ or
\status\ query with \Password\<the password> when the server has
one, and the server never checks it: CFolderJoin::JoinCurGame compares
what the player typed with that value and refuses the join itself. Anyone
who can query the server (or the master server listing it) can read the
password, and a client that skips the check joins anyway.
A real check needs the server to see the password, which the 1.003 handshake does not carry. The fix adds it after the three values every client sends, which a retail server never reads.
0x00408380 (NolfServ.exe) · src/engine/nolfserv/ServerDlg.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, M-P6-12):CServerDlg::ParseCommandLinereads-nowizwherever it is, and only the options that take a value need one after them. The release build keeps the retail parser.
NolfServ.exe -nowiz is the obvious way to start the dedicated server
without its setup wizard, from the settings saved in NetHost.txt, and it
does not work: the parser stops as soon as the argument it looks at is the
last one, because it takes every option to have a value after it,
-nowiz included. -nowiz only counts with another argument behind it
(-nowiz -NetPort 27888, or -nowiz twice).
0x00449550 (NolfServ.exe, the option table) · src/engine/nolfserv/ServerDlg.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, M-P6-12): each-NetLevel <world>(or-NetCALevel) adds a world to the game type's list (NetLevel0,NetLevel1, ... andNetNumLevels), the first one replacing the saved list. The release build keeps the retail table.
The command line can set any of the server's 27 settings as -<setting> <value>, and the table includes -NetLevel and -NetCALevel. They write
the settings NetLevel and NetCALevel, which nothing reads: the levels
are NetLevel0, NetLevel1, ... (and NetCALevel0, ...), counted by
NetNumLevels (NetCANumLevels). The level list can only be set by the
wizard, or by editing NetHost.txt.
GetMovementData (Object.lto) · src/game/ObjectDLL/AIMovement.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, M-P5-01): the time is clamped to 0..1 and the last sample is used without a neighbour (P5_GetMovementData,src/modern/object/p5_fixes.cpp).
AI dodges and cover moves are driven by tables of distances sampled over the
animation (s_afRifleShuffleLeft ... s_afPistolCornerLeft, each cData+1
floats). The lookup interpolates between afData[int(fTime*cData)] and
afData[int(fTime*cData+1)], with fTime the animation's time over its
length. When the animation reaches its end, fTime is 1.0 and the second
index is cData+1, one past the table. Its weight is 0, which hides the read
for most tables (the next table's first value follows), but not for the last
one: in every retail OBJECT.lto the word after s_afPistolCornerLeft is
0xffffffff, a NaN, and 0 times a NaN is a NaN. A pistol-armed AI finishing a
cover move to the left is moved by a NaN distance and vanishes. (An animation
of length 0 makes fTime 0/0, a NaN, with the same result.)
Door::InitialUpdate, Door::CalculateNewPos (Object.lto) · src/game/ObjectDLL/Door.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, M-P5-03): a zero direction is left zero, a door already at its destination arrives, and a door loaded from a retail save gets its NaN direction and open position repaired (src/modern/object/p5_fixes.cpp).
InitialUpdate normalises MoveDir with VEC_NORM, which multiplies by
1/|v|: a zero vector becomes 0 * inf, NaN in every component, and the open
position computed from it is NaN too. Switch declares MoveDir with a
default of (0, 0, 0). Opening such a door moves it towards a NaN position
(CalculateNewPos takes its else branch: the x87 compare with a NaN is
unordered), and vNewPos == m_vOpenPos is never true, so it never reaches
the open state. A Switch still sends its trigger message (that happens in
SetOpening), which is why such switches seem to work. CalculateNewPos
also normalises the remaining distance without a check, so a door that has
arrived, or does not move, computes NaNs as well. The NaNs are saved with the
game and come back on load.
CPlayerObj::ProcessCommand (Object.lto) · src/game/ObjectDLL/PlayerObj.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, M-P5-04): with no horizontal direction the player's rotation is left alone.
Turning the player to face an object, the direction is flattened to the
horizontal and normalised with VEC_NORM. An object straight above or below
the player leaves a zero vector, which becomes NaN, and the player's
rotation is set from it.
CCharacterFX::InitLocalPlayer (CShell.dll) · src/game/ClientShellDLL/CharacterFX.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, M-P5-06): an invalid style falls back to the default one.
The player's model style comes from the server in the character's create
struct and goes straight into CModelButeMgr::GetStyleName, which returns
m_aStyles[eStyle].m_szName with no range check, and into
GetPlayerHeadOffset. eModelStyleInvalid is 0xFF, so an invalid style
reads 255 entries into a heap array that holds a handful.
CFolderPlayer::OnFocus (CShell.dll) · src/game/ClientShellDLL/FolderPlayer.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, M-P5-09): the settings are compared where they are, without a copy.
Leaving the multiplayer player setup, the folder checks for changes by
copying each console value into char szTemp[32] and comparing. The model
and name copies are bounded (SAFE_STRCPY, which truncates them, so a long
one always reads as changed); NetPlayerSkin and NetPlayerHead are copied
with GetConsoleString, a plain strcpy, and hold "display name,file name"
pairs that are usually longer than 31 characters. In the shipped build's
stack frame the overrun lands in space only another branch uses, so it does
no visible harm there; another frame layout crashes.
0x10024802 (server.dll) · src/engine/server/objtypes.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN):src/engine/server/objtypes.cppties the link off like the other four. The release build keeps the retail setup.
Of the five links the function sets up, m_StandingLink (+0x114) is the
only one given its m_pData and not tied off, so an object that has never
stood on anything carries a link whose m_pPrev/m_pNext are whatever the
object bank's memory held. (It also ties off the link at +0x060 twice,
which is harmless.)
0x10023495 (server.dll) · src/engine/server/netmgr.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): a record that cannot be allocated is not appended, and the fragment is dropped as an invalid one would be (src/engine/server/netmgr.cpp). The release build keeps the retail code.
When the six-record cap is not reached the function does new CNetFragRec,
and on failure (xor %edi,%edi at 0x100234f2) both paths still call
LMessageList::Append with the result. Append writes through its argument
either way (0x10024311, 0x100242f5), so running out of memory here is a
write through null, not a dropped fragment.
0x10004512 (server.dll) · src/engine/server/worldsim.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): a move parallel to the contact plane is left as it is (src/engine/server/worldsim.cpp). The release build keeps the division.
The divisor reduces to the dot product of the move with the contact plane's normal. A move exactly parallel to a contact plane divides by zero, and the infinity or NaN is copied into the object's position.
0x10001cad (server.dll) · src/engine/server/worldsim.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): a polygon of more than 40 vertices, or a clip that would run past the 200-entry pool or pointer list, is answered as blocking, the safe mistake for a polygon already inside the sphere test (src/engine/server/worldsim.cpp). The release build keeps the retail code.
The clip pool (200 vertices) and the working pointer list (200) are
allocated across all six clip rounds and never reset, and the input list
holds 40 pointers filled straight from m_nVertices. A polygon with more than
40 vertices, or a clip sequence producing more than 200 new vertices, writes
past the end of the stack frame (0x10001d36-0x10001e6d).
0x100012b6 (server.dll) · src/engine/server/modelanim.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN):src/engine/server/modelanim.cppwraps a zero-length animation to time 0. The release build keeps the retail division.
A looping animation past its end wraps with m_Cur.m_Time %= nLength, where
nLength is the last keyframe's time. The only guard is nKeyFrames > 1, so
an animation with several keyframes all at time 0 divides by zero. (The
blend-percentage division just before it, 0x10001320, does check.)
0x10029087 (server.dll) · src/engine/server/servercommon.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN):src/engine/server/servercommon.cppsends0xfffffor a missing first identifier, as for the other two. The release build keeps the retail read.
Of the three file identifiers it sends, the first is read through
m_pSData->m_pFiles[0]->m_FileID (0x10029092) with no null test; the other
two are tested and replaced by 0xffff when missing. An object with no model
faults here.
0x0040bf30 (lithtech.exe) · src/engine/lithtech/clientinterface.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN):src/engine/lithtech/clientinterface.cppreturns on a null handle. The release build keeps the retail read.
Every other accessor in the client interface checks the handle for null; this
one reads the object type straight off it, so a null handle is a read of
address 0xbc.
0x0040cfd0 (lithtech.exe) · src/engine/lithtech/clientinterface.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN):src/engine/lithtech/clientinterface.cppzeroes only the outputs it was given. The release build keeps the retail writes.
Given a surface, it checks both output pointers and fills in whichever were asked for. Given a null surface, it zeroes both without checking either.
0x004363f0 (lithtech.exe) · src/engine/lithtech/clientinterface.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): only the node-count bound refuses a node handle, and the copy is terminated (src/engine/server/commonmodel.cpp). The release build keeps the retail checks.
The node handle is tested against null before it is tested against the node
count, so node 0, a valid node, is refused as an invalid parameter. The copy
is strncpy(pName, ..., maxLen - 1) with nothing writing the terminator, so
a name of maxLen - 1 characters or more comes back unterminated.
0x100070b6 (server.dll) · src/engine/server/filemgr.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): the path is written with_snprintfbounded bynOutLen, always terminated, and one that does not fit is reported as a failure (src/engine/server/filemgr.cpp). The release build keeps thesprintf.
The fourth argument, nOutLen, is the size of the caller's buffer; the body
is sprintf(pOutPath, "%s\\%s", pTree->m_szDir, pFilename) and never reads
it, so a long path overruns the buffer (cb_LoadServerObjects passes 256
bytes).
0x1000e05b (server.dll) · src/engine/server/counter.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): thestrncpycopy is terminated, the temp-directory path is written with a bounded_snprintf, and the tree path goes through the bounded builder; a name that does not fit fails as a copy error would (src/engine/server/counter.cpp). The release build keeps the retail copies.
Extracting an object DLL to a temporary file, the GetTempFileName fallback
copies with strncpy(pOutPath, sUnique, nOutLen - 1) and nothing writes the
terminator, which strncpy does not add when the source fills the count. The
other two paths sprintf into the caller's buffer without any bound.
0x100260d9, 0x1002616a (server.dll) · src/engine/server/servermsg.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): both compare against the allocation's full 32-bit size (src/engine/server/servermsg.cpp). The release build keeps the 16-bit compares.
Both compare the position against (uint16)m_Data.m_AllocSize, a 16-bit
truncation of a 32-bit field (0x1002619e, 0x10026102), while
CPacket::WriteType (0x10013957) compares the same field as a full 32-bit
value. Only the 0x1000-byte cap on packets keeps it from mattering.
0x100d7be0, 0x100d6d40 (Object.lto) · src/game/ObjectDLL/GameServerShell.cpp;
0x10103e10 (Object.lto) · src/game/ObjectDLL/PlayerObj.cpp;
0x100343d0 (CShell.dll) · src/game/ClientShellDLL/InterfaceMgr.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, M-P6-01):FilterFormatChars(src/game/Shared/CommonUtilities.cpp) removes%from player names and chat on the server (GameServerShell.cpp,PlayerObj.cpp), from names and chat a client receives (InterfaceMgr.cpp,src/modern/cshell/p6_multiplayer.cpp), from the host's own session and player names (FolderHost.cpp,FolderPlayer.cpp) and from server and player names in the browser (FolderJoin.cpp). The release build keeps the retail code.
CGameServerShell::OnMessage passes the chat line a client sent straight
to g_pLTServer->CPrint(szString) as the format (MID_PLAYER_MESSAGE, the
team and ghost variants alike), then relays it to every client, whose
CMessageMgr::AddLine does g_pLTClient->CPrint(pStr) with it. Player
names travel the same way: CInterfaceMgr::OnMessage formats them into
the kill, score and join messages and prints the result with
CPrint(GetStringData(hStr)). A %n in a name or chat line writes
through whatever the stack holds, on the server and on every client: in
the release build, a client named Retail%n%s joining and chatting
%n%n%n%n%s%s%s%s crashed the host with a write access violation inside
the engine's formatter (tested with two copies of the game on one docker
network). This is the publicly documented LithTech format-string
vulnerability.
0x1001e330 (CShell.dll) · src/game/ClientShellDLL/FolderJoin.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, M-P6-02):CFolderJoin::UpdateOptionstreats a value outside the table as 0. The release build keeps the retail code.
For a toggle or cycle option, the value a server reports in its GameSpy
reply is used as an index, pOpt->nStringId[nVal], into a five-entry
array (SO_MAX_STRINGS), and the string ID found there is loaded and
shown. Any server, or anything answering the browser's query, can make the
browser read past the array.
0x00404cc0 (NolfServ.exe), called from 0x00405300 and 0x00406090 · src/engine/nolfserv/ServerDlg.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, M-P6-14):CServerDlg::UpdateUIandOnConsoleSendpass their text with a"%s"format. The release build keeps the retail calls.
CServerDlg::WriteConsoleString is printf-style, and its two callers with
text from outside pass that text as the format: UpdateUI, every line of
the engine's and the game's console output (which carries player names),
and OnConsoleSend, the command the operator typed. A % in a line is read
as a conversion: %s and %n in a player's name read and write through
whatever the stack holds. (The same flaw as the game's own,
Player names and chat are used as printf format strings,
on the dedicated server's side.)
0x00404cc0 (NolfServ.exe) · src/engine/nolfserv/ServerDlg.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, M-P6-14): the formatted line is cut to 497 characters, terminated, before the line end is added. The release build keeps the retail buffer handling.
WriteConsoleString formats into a 500-byte buffer with _vsnprintf(str, 499, ...), which leaves the buffer without a terminating zero when the
text is 499 characters or more; the strlen that follows then runs past
it, and the strcat of "\r\n" writes past the buffer's end for a line
of 498 characters or more.
0x00404902 (NolfServ.exe, in CServerDlg::DoHost) · src/engine/nolfserv/ServerDlg.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, M-P6-14):DoHoststops at the table's 20 entries. The release build keeps the retail loop.
DoHost copies one value per server option of
Attributes\ServerOptions.txt into the NetGame it hands the game,
whose m_fOptions has room for MAX_GAME_OPTIONS (20), with no check. The
retail file has 17 options; a modified one with more than 20 overwrites
the level count and the levels that follow the table.
0x0042de00 (lithtech.exe) · src/engine/lithtech/clientinterface.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN):src/engine/lithtech/clientinterface.cppcloses it after the walk. The release build keeps the leak.
It walks every *.ren beside the executable with _findfirst/_findnext
and has no _findclose on any path.
0x0043fcd0 (lithtech.exe) · src/engine/lithtech/clientmusic.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN):src/engine/lithtech/clientmusic.cppfrees the path on the failure path too. The release build keeps the leak.
For a control-file name with no directory, the function allocates a full path
(0x0043fde5) and frees it only when the reader's Init succeeds
(0x0043fecb). The failure path returns at 0x0043fec8 without freeing it.
0x10035a8d (server.dll) · src/engine/server/streamsim.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN):src/engine/server/streamsim.cppcloses the file when the stream cannot be allocated. The release build keeps the leak.
fopen succeeds, the stream object fails to allocate, and the function
returns null without closing the file.
0x1002af75 (server.dll) · src/engine/server/serverengine.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): the record is freed whether or not a manager is left (src/engine/server/serverengine.cpp). The hash is still reached through the global, which is null once the manager has gone, where the record's copy would dangle. The release build keeps the leak.
The callback exists to free a dependency record, using the manager it is
handed. It reads the global manager pointer (0x10058bf0) instead, and when
that is null the whole body is skipped, so the eight-byte record is lost.
(se_LoadChildModel stores the manager in the record, and nothing reads it.)
0x00409870 (NolfServ.exe, CWizardDlg::CWizardDlg) · src/engine/nolfserv/WizardDlg.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN, M-P6-14): the search is closed once it has answered. The release build keeps the leak.
To decide whether to show its customization page, the wizard looks for a
.rez file in the Custom folder with _findfirst, and never calls
_findclose on the handle it gets back.
Minor: these change what the engine reports, not what it does.
✅ FIXED in the modern build (
NOLFSRC_MODERN): every one in the table reports its own name (src/engine/lithtech/clientinterface.cpp,clientphysics.cpp,src/engine/server/servermessage.cpp, andcommonmodel.cpp's twin ofci_GetModelNodeName), andCPhysicsLT::SetObjectDims's first check uses the client's debug level. The release build keeps the retail names.
The engine's parameter checks report through a macro taking the function's own name. These were written by copying a neighbour, and the name was never changed:
| function | address | reports itself as |
|---|---|---|
ci_GetParticleTotalLifetime |
0x0040b750 (lithtech.exe) |
CLTClient::GetParticleLifetime |
ci_FreeServiceList |
0x004081e0 (lithtech.exe) |
CLTClient::GetServiceList |
ci_SetSurfaceAlpha |
0x0040ecc0 (lithtech.exe) |
ILTClient::GetSurfaceAlpha |
ci_UnoptimizeSurface |
0x0040cdf0 (lithtech.exe) |
OptimizeSurface |
ci_RenderObjects |
0x00408f90 (lithtech.exe) |
RenderCamera, at three of its four checks |
ci_GetModelNodeName |
0x004363f0 (lithtech.exe) |
GetNextModelNode, at both |
CPhysicsLT::SetObjectDims |
0x00405000 (lithtech.exe) |
SPhysicsLT::SetObjectDims, at the first check (which also uses the server's debug level, 2, where the next uses the client's 1) |
CLTServer::UnloadModel, UnloadTexture |
server.dll | ThreadLoadTexture, in both |
✅ FIXED in the modern build (
NOLFSRC_MODERN): all three report at 1, like their siblings (src/engine/lithtech/clientinterface.cpp,ScaleSurfaceToSurface's internal half included). The release build keeps the retail levels.
A check's message prints only when g_DebugLevel is at least the level it
names, and in the client interface that is almost always 1:
ci_ScaleSurfaceToSurface(0x0040dc50) reports at 4, where its seven siblings in the blit family use 1.ci_RenderCamera(0x00408ea0) reports all three checks at 15, which nothing sets, so they can never print.ci_GetPolyGridInfo(0x0040bc80) reports at 0, so it always prints.
0x0041f150-0x0041fbd0 (lithtech.exe), and their server.dll twins ·
src/engine/server/csbase.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN): the two error macros they use pass the single space every other check passes (src/engine/server/de_error.h). The release build keeps the retail description.
ILTCSBase's error reporter takes a description as its last argument. Every
method returning LTRESULT passes a single space; every method returning
void or a value passes the engine's pending shutdown-message buffer (the one
ci_Shutdown hands to ci_ShutdownWithMessage). It is empty in practice; if
a shutdown message were ever pending, all twenty-nine of those errors would
quote it.
0x10007854 (server.dll) · src/engine/server/fsstream.cpp
✅ FIXED in the modern build (
NOLFSRC_MODERN):src/engine/server/fsstream.cpppasses the size. The release build keeps the retail call.
The format is "read from stream %p size %u" and only this is passed, so
%u prints whatever is next on the stack. It fires only at stream debug level
4 or above; CTreeStream::Read passes both.
0x004ab834 (lithtech.exe, the string)
✅ FIXED in the modern build (
NOLFSRC_MODERN):src/engine/lithtech/clientmusic.cppspells itsDefine3. The release build keeps the retail string.
... sDefine1=%s sDefine2=%s sDeine3=%s: sDeine3 for sDefine3, in every
trace the music system's level setup writes at debug level 3.