Skip to content

Fix/thread safety bullet particle - #652

Closed
noisethanks wants to merge 2165 commits into
themrdemonized:all-in-one-vs2022-wpofrom
noisethanks:fix/thread-safety-bullet-particle
Closed

noisethanks wants to merge 2165 commits into
themrdemonized:all-in-one-vs2022-wpofrom
noisethanks:fix/thread-safety-bullet-particle

Conversation

@noisethanks

@noisethanks noisethanks commented Aug 29, 2026

Copy link
Copy Markdown

Fix unsynchronized concurrent access: bullet manager and particle system

Found while investigating thread safety in the engine's multithreaded
render/game-logic split. Both fixes are self-contained. Neither depends
on or relates to any TBB work.

  1. CBulletManager::Clear() took no lock. UpdateWorkload, Render, and
    AddBullet in the same class all correctly guard m_Bullets with m_Lock.
    UpdateWorkload runs on GameThread. Render runs on the main thread. This
    is genuine concurrent access, on by default via mtBullets. Clear() only
    runs on level load or disconnect, so it's not on the hot path, but it
    was the one unguarded member of an otherwise consistent trio. Added the
    same lock.

Also removed a stale DEBUG-only thread assertion in AddBullet. It
claimed AddBullet is main-thread-only. It isn't: several weapon/explosive
paths and a Lua binding call it off-main today. The matching assertion
in UpdateWorkload was already removed. This restores consistency.

  1. Three sites touch Device.seqParallelBeforRender with no lock:
    SItem::OnFrame and SItem::~SItem write to it from a worker thread, and
    CRenderDevice::on_idle drains it on the main thread every frame. The
    existing onframe_lock doesn't cover this, since it's a per-particle-group
    member and can't protect a shared, device-level vector. Added a new
    lock on CRenderDevice, following the same pattern already used elsewhere
    in the codebase for this exact producer/drainer shape.

Found but not fixed: SItem's destructor has a separate, pre-existing,
same-thread hazard if a deferred callback ever destroys an SItem
mid-drain. Not currently reachable and unrelated to the race above.
Flagging it rather than expanding scope here.

Tested across multiple sessions, including firefights with active
particle effects. No crashes, hangs, or behavior changes observed. This
confirms the new locking doesn't break anything. It doesn't prove the
original race ever caused a specific crash. Both were unsynchronized
shared-state access regardless of whether either was ever observed to
fail.

Edit: Upon closer inspection, it seems that the SItem iterator hazard is reachable. Second commit addresses it.

# Conflicts:
#	src/xrGame/console_commands.cpp
0.5*ssaDiscard will draw half of objects,
0.25*ssaDiscard will draw 1/4 of objects
less than that is not drawn
…ncrease density of closer objects, > 1 will reduce

* Apply gradient cutoff to DetailManager
…ike lasersights, 0.5 exp to keep statics dense
* use IRenderVisualFlags::eIgnoreOptimization to toggle ssa discard per pVisual
* Hud geometry and script attachments (hud and cam) have this flag
noisethanks and others added 28 commits June 29, 2026 23:41
…guards

- Add r__tex_evict_interval cvar (default 600, min 60, max 3600)
  controls frames between eviction passes, previously hardcoded
- Wrap all TexEvict Msg() calls and counter variables in #ifdef DEBUG
  so release builds have zero logging overhead
- Four cvars now expose the complete eviction tuning surface:
  r__tex_evict_enabled, r__tex_evict_age_frames,
  r__tex_evict_batch_size, r__tex_evict_interval
…stalled-textures

feat(renderer): mid-session texture eviction system (DX11)
alife: guard against _SPAWN_ID(-1) in spawn graph traversal
(cherry picked from commit ad92b915c9a4a4c2124d054056be2ddc6834e5d8)
…xture-reload-thread-safety

Address a race condition with reloading evicted textures by using texture_load_tasks PPL group.
# Conflicts:
#	src/xrGame/Level_Bullet_Manager.h
# Conflicts:
#	src/xrGame/Actor.cpp
#	src/xrGame/ui/UI3tButton.cpp
#	src/xrGame/ui/UIFrameLineWnd.h
#	src/xrGame/ui/UIMotionIcon.h
CBulletManager::Clear() (Level_Bullet_Manager.cpp) took no lock, while
UpdateWorkload/Render/AddBullet in the same class all correctly guard
m_Bullets with m_Lock. UpdateWorkload runs on GameThread, Render on the
main thread - genuine concurrent access, gated on mtBullets (on by
default). Added the same m_Lock guard to Clear() for consistency.

Also removed a stale DEBUG-only VERIFY(m_thread_id ==
GetCurrentThreadId())
in AddBullet - it asserts main-thread-only, but AddBullet legitimately
runs off-main today (ShootingObject, WeaponKnife, Explosive, Lua's
level.add_bullet). The matching assertion in UpdateWorkload was already
removed; this restores consistency.

CParticleGroup: three sites touch Device.seqParallelBeforRender with no
synchronization - SItem::OnFrame (worker thread) and SItem::~SItem
(either thread) both push/erase entries, while CRenderDevice::on_idle
(main thread) drains the vector every frame. The existing onframe_lock
doesn't cover this - it's per-CParticleGroup, so it can't exclude
different groups from each other on this shared Device-level vector.
Added a new CRenderDevice member, seqParallelBeforRenderCS, guarding all
three sites - same pattern already used elsewhere for this exact
producer/drainer shape (CEventAPI::CS, CEffect_Rain::rainCS).

Tested across multiple sessions including firefights with active
particle
effects - no crashes, hangs, or behavioral regressions.
The main thread drains Device.seqParallelBeforRender in CRenderDevice::on_idle
and invokes every registered callback. Two callbacks are registered across the
codebase. PS::CParticleGroup::SItem::DelayDeleteChilds comes from the particle
system, and CObjectList::ProcessDestroyQueue is registered whenever mt_Scheduler
is set, which is the default.

ProcessDestroyQueue destroys queued objects synchronously. That reaches
IGame_ObjectPool::destroy and xr_delete, then ~IRenderable and model_Delete.
g_bRendering is false during the drain, so CModelPool takes the immediate branch
and deletes the visual outright. When that visual is a particle group its
destructor clears items, every SItem destructor runs, and each one erased its
own entry from Device.seqParallelBeforRender while the drain loop was still
iterating over that vector. SItem::Clear does not empty _children_destroy, so
the early return at the top of ~SItem does not prevent this.

~SItem now clears its delegate in place rather than erasing it, which leaves the
vector size untouched for the duration of the drain. The drain skips cleared
entries and clear()s the whole vector when it finishes, so a callback belonging
to a destroyed SItem is still never invoked and cancellation keeps working. The
drain loop now indexes by position and copies each delegate before invoking it,
so an entry appended by a callback is handled safely as well.

Also guarded the two CObjectList accesses to this vector with
seqParallelBeforRenderCS. Both run on the main thread and neither currently
overlaps the drain, so this is consistency with the invariant the previous
commit established rather than a fix for a live race.
@themrdemonized

themrdemonized commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Redo this PR to target MT branch, currently this will merge all MT work (2k commits) into main which is not what i want for now

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants