;
; ============================================================
;  NeoParticleAnimator
;  Real-Time Particle Engine Plug-In for VisualNEO Win
;  PureBasic x86 Shared DLL
;
;  -- Public API (four actions) --
;
;    NeoParticle_Play  rect, preset, intensity, color
;       rect       VNW Rectangle name (host for the overlay)
;       preset     One of: Sparkle, Fire, Rain, Snow,
;                  Confetti, MagicDust, Explosion, LightRays,
;                  Beams, Glow, Stars, LightBeams, GodRays, Laser,
;                  Bubbles, PulseWave, Aurora, Shimmer, AuroraBorealis
;       intensity  0-100 (50 = preset default; 100 = 2x; 0 = off)
;       color      "" for preset default, or "R,G,B" override
;
;    NeoParticle_PlayAdvanced  rect, preset, intensity, options
;       options    "key=value;key=value;..." string for full control
;                  over emission area, direction, speed, size, life,
;                  density, color gradient, feather, taper, mode,
;                  twinkle, and sprite image.
;
;    NeoParticle_Stop  rect
;       rect       Rectangle name; "" stops ALL emitters
;
;    NeoParticle_StopAll  (no parameters)
;       Hard-stop every active emitter. Releases all timers,
;       windows, and GDI handles. Use in cleanup/exit handlers.
;
;  -- Design-time --
;
;  The Edit dialogs for Play and PlayAdvanced include a live
;  animated preview canvas so the author sees the effect
;  immediately while choosing preset/intensity/color/options.
;
;  Author:  Anthony LaTorre
;
;  Build:
;    PureBasic 6.21 -> Shared DLL, 32-bit (x86), #PB_Ascii
;    Rename .dll -> .nbp for VisualNEO Install Plug-Ins.
; ============================================================

EnableExplicit

; ============================================================
;  Constants
; ============================================================

; VisualNEO action parameter types
#ACTIONPARAM_NONE     = 0
#ACTIONPARAM_ALPHA    = 1
#ACTIONPARAM_NUMERIC  = 3
#ACTIONPARAM_FILENAME = 5

; VisualNEO message codes
#NBMSG_ENTER_RUNMODE = 1
#NBMSG_EXIT_RUNMODE  = 2
#NBMSG_DEACTIVATE    = 3
#NBMSG_ACTIVATE      = 4
#NBMSG_MOVESIZE      = 5
#NBMSG_PAGECHANGE    = 6
#NBMSG_MINIMIZE      = 7
#NBMSG_RESTORE       = 8

; GDI+ interpolation
#InterpolationModeHighQualityBicubic = 7
#InterpolationModeBilinear           = 3
#InterpolationModeNearestNeighbor    = 5

; Multimedia timer constants
#TIME_PERIODIC          = 1
#TIME_CALLBACK_FUNCTION = 0

; Engine limits
#MAX_PARTICLES     = 2000
#PARTICLE_FPS      = 60
#PARTICLE_TIMER_MS = 16    ; ~60 FPS

; Live-preview tuning
#PREVIEW_W       = 290
#PREVIEW_H       = 220
#PREVIEW_FPS_MS  = 33      ; ~30 FPS for design-time preview
#PREVIEW_TIMER_ID = 1

; ============================================================
;  Action IDs (just two now)
; ============================================================
Enumeration 1
  #Action_ParticlePlay         ; 1
  #Action_ParticleStop         ; 2
  #Action_ParticlePlayAdvanced ; 3
  #Action_ParticleStopAll      ; 4
EndEnumeration

; ============================================================
;  GDI+ imports
; ============================================================
Structure GdiplusStartupInput
  GdiplusVersion.l
  DebugEventCallback.i
  SuppressBackgroundThread.l
  SuppressExternalCodecs.l
EndStructure

Import "gdiplus.lib"
  GdiplusStartup(*token, *input.GdiplusStartupInput, *output)
  GdiplusShutdown(token)
  GdipCreateFromHDC(hdc, *graphics)
  GdipDeleteGraphics(graphics)
  GdipSetSmoothingMode(graphics, smoothingMode)
  GdipSetInterpolationMode(graphics, interpolationMode)
  GdipCreateSolidFill(color.l, *brush)
  GdipDeleteBrush(brush)
  GdipSetSolidFillColor(brush, color.l)
  GdipFillEllipseI(graphics, brush, x.l, y.l, width.l, height.l)
  GdipFillEllipse(graphics, brush, x.f, y.f, width.f, height.f)
  GdipGraphicsClear(graphics, color.l)
  GdipCreatePath(fillMode.l, *path)
  GdipDeletePath(path)
  GdipAddPathEllipse(path, x.f, y.f, width.f, height.f)
  GdipAddPathRectangle(path, x.f, y.f, width.f, height.f)
  GdipCreatePathGradientFromPath(path, *brush)
  GdipSetPathGradientCenterColor(brush, color.l)
  GdipSetPathGradientSurroundColorsWithCount(brush, *colors, *count)
  GdipSetCompositingMode(graphics, compositingMode.l)
  GdipSetCompositingQuality(graphics, compositingQuality.l)
  GdipSetPixelOffsetMode(graphics, pixelOffsetMode.l)
  GdipTranslateWorldTransform(graphics, dx.f, dy.f, order.l)
  GdipRotateWorldTransform(graphics, angle.f, order.l)
  GdipResetWorldTransform(graphics)
  GdipCreatePen1(color.l, width.f, unit.l, *pen)
  GdipDeletePen(pen)
  GdipDrawLine(graphics, pen, x1.f, y1.f, x2.f, y2.f)
  GdipDrawEllipse(graphics, pen, x.f, y.f, width.f, height.f)
  GdipFillRectangle(graphics, brush, x.f, y.f, width.f, height.f)
  ; Image / sprite support
  GdipLoadImageFromFile(*filename, *image)
  GdipDisposeImage(image)
  GdipGetImageWidth(image, *width)
  GdipGetImageHeight(image, *height)
  GdipDrawImageRectRectI(graphics, image, dstx.l, dsty.l, dstwidth.l, dstheight.l, srcx.l, srcy.l, srcwidth.l, srcheight.l, srcUnit.l, imageAttributes, callback, callbackData)
  GdipCreateImageAttributes(*imageattr)
  GdipDisposeImageAttributes(imageattr)
  GdipSetImageAttributesColorMatrix(imageattr, type.l, enableFlag.l, *colorMatrix, *grayMatrix, flags.l)
  ; Bitmap creation + draw (used by sprite pre-resize at load)
  GdipCreateBitmapFromScan0(width.l, height.l, stride.l, format.l, scan0, *bitmap)
  GdipGetImageGraphicsContext(image, *graphics)
  GdipDrawImageRectI(graphics, image, x.l, y.l, width.l, height.l)
EndImport

Import "winmm.lib"
  timeSetEvent.i(uDelay.l, uResolution.l, lpFunction.i, dwUser.i, fuEvent.l)
  timeKillEvent.i(uTimerID.l)
  timeBeginPeriod.i(uPeriod.l)
  timeEndPeriod.i(uPeriod.l)
EndImport

; kernel32 -- only GetModuleHandleEx is not auto-imported by PB. Used by
; GetPluginDir to find the DLL we're running inside (passing flag=4 +
; an address inside our own code makes it resolve to OUR module, not the
; host VNW process).
Import "kernel32.lib"
  GetModuleHandleEx_(dwFlags.l, lpModuleName.i, *phModule) As "_GetModuleHandleExA@12"
EndImport

; GDI+ enums
#SmoothingModeAntiAlias      = 4
#CompositingModeSourceOver   = 0
#CompositingQualityHighSpeed = 1
#PixelOffsetModeHighSpeed    = 1

; ============================================================
;  Global state
; ============================================================
Global g_nbWinHandle.i
Global g_nbAddAction.i
Global g_nbAddFile.i
Global g_nbGetVar.i
Global g_nbSetVar.i
Global g_nbInterface.i
Global g_nbPlayAction.i
Global g_hInstance.i

Global g_gdiplusToken.i
Global g_gdiplusStarted.i

; ============================================================
;  Custom preset storage
; ============================================================
; Custom presets are stored in NeoParticleAnimator_Presets.ini in the same
; directory as the plugin .nbp/.dll. Each preset gets a [SectionName] with
; one key per EmitterConfig field.
;
; Resolution order in ApplyPreset:
;   1. Custom INI section matching the name -> use that (overrides built-in)
;   2. Otherwise fall through to built-in Case statements
;
; "Reset to Default" works by deleting the custom INI section -- the built-in
; (if any) becomes visible again at next load.

Global g_pluginDir.s = ""           ; Cached plugin folder, resolved on first use
Global NewList g_customPresetNames.s()    ; Names of custom presets currently in INI

; Metadata string pointers (freed on detach)
Global *gPluginTitle, *gPluginAuthor, *gPluginHint
Global *gAct1_Name, *gAct1_Hint
Global *gAct2_Name, *gAct2_Hint
Global *gAct3_Name, *gAct3_Hint
Global *gAct4_Name, *gAct4_Hint

Structure TActionParams
  p.b[32]
EndStructure

; ============================================================
;  Particle structure
; ============================================================
Structure Particle
  alive.i
  x.f
  y.f
  vx.f
  vy.f
  life.f
  maxLife.f
  size.f
  sizeStart.f
  sizeEnd.f
  r1.i : g1.i : b1.i
  r.i  : g.i  : b.i
  r2.i : g2.i : b2.i
  alpha.f
  gravity.f
  ; Per-particle gravity multiplier (set at spawn from cfg\gravityVariance).
  ; Final gravity force = gravity * gravMod, so particles fall/rise at
  ; slightly different rates -- looks more natural than uniform gravity.
  gravMod.f
  drag.f
  rotation.f
  rotSpeed.f
  fadeIn.f
  fadeOut.f
  twinklePhase.f
EndStructure

; ============================================================
;  Emitter configuration
; ============================================================
Structure EmitterConfig
  ; Spawn
  spawnRate.f
  burstCount.i

  ; Position
  emitX.f
  emitY.f
  emitW.f
  emitH.f

  ; Direction
  spreadAngle.f
  baseAngle.f

  ; Speed
  speedMin.f
  speedMax.f

  ; Size
  sizeStartMin.f
  sizeStartMax.f
  sizeEndMin.f
  sizeEndMax.f

  ; Lifetime
  lifeMin.f
  lifeMax.f

  ; Color
  colorStartR.i : colorStartG.i : colorStartB.i
  colorEndR.i   : colorEndG.i   : colorEndB.i
  ; Optional 3rd color stop ("mid") for richer gradients. Fire benefits
  ; dramatically: yellow-white -> orange -> dark red (3-stop) looks far
  ; better than yellow-to-red (2-stop, which passes through muddy oranges).
  ; hasColorMid = 0: ignore mid, lerp Start->End directly (legacy behaviour)
  ; hasColorMid = 1: Start->Mid (0..midPos) then Mid->End (midPos..1)
  colorMidR.i : colorMidG.i : colorMidB.i
  colorMidPos.f          ; 0.0..1.0, where the mid stop sits in life
  hasColorMid.i

  ; If 1, an explicit color override was applied (suppresses preset-special
  ; colour behaviour like the Confetti rainbow randomiser).
  colorOverride.i

  ; Physics
  gravity.f
  drag.f
  ; Wind: constant velocity acceleration applied each frame (px/sec/sec).
  ; Useful for breeze on smoke, drift on snow, sideways embers.
  windX.f
  windY.f
  ; Gravity variance: each particle gets a random gravity multiplier
  ; within +/- this fraction at spawn. 0 = uniform gravity (all particles
  ; fall/rise at same rate). 0.3 = +/- 30% per particle (natural variation).
  gravityVariance.f
  ; Perlin noise turbulence: applies turbulent acceleration each frame
  ; based on sampling 2D noise at the particle's position. Creates the
  ; organic dancing motion of real fire/smoke/dust.
  ;   noisePower  - acceleration magnitude (px/sec/sec). 0 = no noise.
  ;   noiseScale  - spatial frequency of noise (higher = finer-grained).
  ;   noiseSpeed  - how fast the noise field shifts over time.
  noisePower.f
  noiseScale.f
  noiseSpeed.f

  ; Alpha
  alphaStart.f
  alphaEnd.f
  fadeInFraction.f
  fadeOutFraction.f

  ; Rendering
  glowSoftness.f
  additiveBlend.i
  feather.f
  ; Taper: shape the spawn region into a trapezoid for a more natural
  ; "fan out" look (think moonlight reflection: narrow at horizon, wide
  ; at foreground). 0 = off, 1 = max:
  ;   0.0 = no taper (normal rectangle, default)
  ;   0.5 = trapezoid, narrow edge is 50% width
  ;   1.0 = full taper, narrow edge is a single point (triangle)
  ; Only affects spawn position; particle behaviour is otherwise unchanged.
  taper.f
  ; Which edge is the narrow one:
  ;   0 = top is narrow, bottom is wide (default; moonlight reflection)
  ;   1 = bottom is narrow, top is wide (fountain spray, inverted funnel)
  ;   2 = left is narrow, right is wide (horizontal fan, e.g. trumpet flare)
  ;   3 = right is narrow, left is wide
  taperEdge.i
  stretch.f
  shape.i
  twinkleSpeed.f
  twinkleAmount.f

  ; Light shaping
  pulseSpeed.f
  pulseAmount.f
  beamCore.f
  beamHalo.f
  beamEndGlow.f
  burstRays.i
  burstSpread.f
  burstWidth.f
EndStructure

; Memory cache for custom presets: preset name (lowercased) -> EmitterConfig.
; Populated by RefreshCustomPresetList from the INI. ApplyPreset reads from
; this cache instead of re-opening the INI on each Play -- this bypasses a
; PB preferences-engine state issue that caused repeated LoadCustomPreset
; calls in the same session to silently read default values instead of the
; actual saved values.
Global NewMap g_customPresetCache.EmitterConfig()

; Shape constants
#SHAPE_CIRCLE     = 0
#SHAPE_RAY        = 1
#SHAPE_BEAM       = 2
#SHAPE_GLOW       = 3
#SHAPE_STAR       = 4
#SHAPE_LIGHTBURST = 5
#SHAPE_LASER      = 6
#SHAPE_RECT       = 7
#SHAPE_SPRITE     = 8
#SHAPE_RING       = 9

; ColorMatrix struct for GDI+ image tinting (5x5 row-major float matrix)
Structure ColorMatrix5x5
  m.f[25]
EndStructure

; GDI+ Unit enum values (only need UnitPixel)
#GdipUnitPixel = 2

; ============================================================
;  Emitter instance (runtime state)
; ============================================================
Structure ParticleEmitter
  rectName.s
  hHost.i
  hOverlay.i

  config.EmitterConfig
  presetName.s
  ; LCase'd cache of presetName, set when emitter is configured. Used by
  ; the per-particle motion code (snow wobble, bubbles wobble, aurora sway)
  ; which would otherwise pay for LCase() on every particle every frame.
  lcPresetName.s

  particles.Particle[#MAX_PARTICLES]
  activeCount.i

  spawnAccum.f

  hDIB.i
  *dibBits
  dibW.i
  dibH.i
  refH.i           ; reference DIB height captured at first spawn; velocity scales by dibH/refH

  timerID.i
  running.i
  burstDone.i

  lastTickCount.i
  timeAcc.f

  lastHostW.i
  lastHostH.i

  topMost.i

  ; Sprite support (used when config\shape = #SHAPE_SPRITE).
  ; The image is loaded once per emitter and drawn many times per frame.
  spritePath.s
  hSpriteImage.i
  spriteW.i
  spriteH.i
EndStructure

; ============================================================
;  Global maps
; ============================================================
Global NewMap gEmitters.ParticleEmitter()  ; key = LCase(RectName)

#WM_PARTICLE_TICK = $8010

; ============================================================
;  VisualNEO string helpers (ANSI, GlobalAlloc_)
; ============================================================

Procedure.i MakeNBString(text.s)
  Protected bytes.i = StringByteLength(text, #PB_Ascii) + 1
  Protected *p = GlobalAlloc_($0040, bytes)
  If *p
    PokeS(*p, text, -1, #PB_Ascii)
  EndIf
  ProcedureReturn *p
EndProcedure

Procedure SetOutPtr_NoFree(*pDest.Long, *src)
  If *pDest
    PokeL(*pDest, *src)
  EndIf
EndProcedure

Procedure.i PtrReadable(*p, bytes.i)
  If *p = 0 Or bytes <= 0 : ProcedureReturn #False : EndIf
  ProcedureReturn Bool(IsBadReadPtr_(*p, bytes) = 0)
EndProcedure

Procedure.i PtrWritable(*p, bytes.i)
  If *p = 0 Or bytes <= 0 : ProcedureReturn #False : EndIf
  ProcedureReturn Bool(IsBadWritePtr_(*p, bytes) = 0)
EndProcedure

Procedure.s SanitizeParamString(raw.s)
  If raw = "" : ProcedureReturn "" : EndIf
  Protected i.i, c.i, clean.s = "", len.i = Len(raw)
  If len > 500 : ProcedureReturn "" : EndIf
  For i = 1 To len
    c = Asc(Mid(raw, i, 1))
    If (c >= 32 And c <= 126) Or c = 9 Or c = 10 Or c = 13
      clean + Chr(c)
    Else
      Break
    EndIf
  Next
  ProcedureReturn clean
EndProcedure

Procedure.s ReadParamString(*Params, index.i)
  If *Params = 0 : ProcedureReturn "" : EndIf
  Protected *pSlot.Integer = *Params + index * 4
  If *pSlot = 0 : ProcedureReturn "" : EndIf
  Protected *val = PeekL(*pSlot)
  If *val = 0 : ProcedureReturn "" : EndIf
  If IsBadReadPtr_(*val, 1)
    ProcedureReturn ""
  EndIf
  Protected raw.s = PeekS(*val, -1, #PB_Ascii)
  ProcedureReturn SanitizeParamString(raw)
EndProcedure

Procedure.i ReadParamInt(*Params, index.i, defaultValue.i = 0)
  Protected s.s = ReadParamString(*Params, index)
  If s = "" : ProcedureReturn defaultValue : EndIf
  ProcedureReturn Val(s)
EndProcedure

Procedure.f ReadParamFloat(*Params, index.i, defaultValue.f = 0.0)
  Protected s.s = ReadParamString(*Params, index)
  If s = "" : ProcedureReturn defaultValue : EndIf
  ProcedureReturn ValF(s)
EndProcedure

Procedure WriteParamString(*Params, index.i, value.s)
  If *Params = 0 : ProcedureReturn : EndIf
  Protected *slot = *Params + index * 4
  If PtrWritable(*slot, 4) = 0 : ProcedureReturn : EndIf
  PokeL(*slot, MakeNBString(value))
EndProcedure

; ============================================================
;  VisualNEO variable access
; ============================================================

Procedure SetNBVar(varName.s, value.s)
  If g_nbSetVar
    Protected *pVar = MakeNBString(varName)
    Protected *pVal = MakeNBString(value)
    If *pVar And *pVal
      CallFunctionFast(g_nbSetVar, *pVar, *pVal)
      GlobalFree_(*pVar) : GlobalFree_(*pVal)
    Else
      If *pVar : GlobalFree_(*pVar) : EndIf
      If *pVal : GlobalFree_(*pVal) : EndIf
    EndIf
  EndIf
EndProcedure

Procedure.s GetNBVar(varName.s)
  If g_nbGetVar
    Protected *pVar = MakeNBString(varName)
    If *pVar
      Protected *pRet = CallFunctionFast(g_nbGetVar, *pVar)
      GlobalFree_(*pVar)
      If *pRet And PtrReadable(*pRet, 1)
        ProcedureReturn SanitizeParamString(PeekS(*pRet, -1, #PB_Ascii))
      EndIf
    EndIf
  EndIf
  ProcedureReturn ""
EndProcedure

Procedure SetLastError(msg.s)
  SetNBVar("[NeoParticle.LastError]", msg)
EndProcedure

; ============================================================
;  nbInterface helpers
; ============================================================

Procedure.i GetObjectHandle(objName.s)
  If g_nbInterface = 0 : ProcedureReturn 0 : EndIf
  Protected p.i = MakeNBString(objName)
  If p = 0 : ProcedureReturn 0 : EndIf
  Protected success.i = CallFunctionFast(g_nbInterface, 7, @p)
  Protected hwnd.i = 0
  If success <> 0 And p <> 0
    If IsBadReadPtr_(p, 1) = 0
      hwnd = Val(SanitizeParamString(PeekS(p, -1, #PB_Ascii)))
    EndIf
  EndIf
  If p : GlobalFree_(p) : EndIf
  ProcedureReturn hwnd
EndProcedure

Procedure.s SelectRectangle(initialName.s)
  If g_nbInterface = 0 : ProcedureReturn "" : EndIf
  Protected *pData.Integer
  *pData = MakeNBString(initialName)
  Protected success.i = CallFunctionFast(g_nbInterface, 8, @*pData)
  Protected result.s = ""
  If success <> 0
    If *pData And IsBadReadPtr_(*pData, 1) = 0
      result = SanitizeParamString(PeekS(*pData, -1, #PB_Ascii))
    EndIf
  EndIf
  If *pData : GlobalFree_(*pData) : EndIf
  ProcedureReturn result
EndProcedure

Procedure RunScript(script.s)
  If g_nbPlayAction = 0 Or script = "" : ProcedureReturn : EndIf
  Protected *pScript = MakeNBString(script)
  If *pScript
    CallFunctionFast(g_nbPlayAction, *pScript)
  EndIf
EndProcedure

; ============================================================
;  GDI+ initialisation
; ============================================================

; ============================================================
;  Value-noise turbulence (Perlin-like 2D noise)
; ============================================================
; A 256x256 table of pseudo-random floats in [0,1], filled deterministically
; at plugin load. NoiseSample bilinearly interpolates within the table with
; smoothstep easing -- visually equivalent to value noise (cheaper than true
; Perlin since we skip gradients) but smooth enough for turbulence.
;
; Used by fire/smoke/magic-dust style presets to add organic dancing motion.
; Each frame each particle samples noise at its position; the result perturbs
; velocity to create natural turbulence.
;
; Declared here (before EnsureGDIPlus) so InitNoiseTable() can be called
; from EnsureGDIPlus -- PB needs the symbol to exist at point of use.

#NOISE_SIZE = 256
#NOISE_MASK = 255

Global Dim gNoiseTable.f(#NOISE_SIZE * #NOISE_SIZE - 1)
Global gNoiseInitialized.i = 0

Procedure InitNoiseTable()
  If gNoiseInitialized : ProcedureReturn : EndIf
  ; Deterministic seed -- same noise across runs so behaviour is reproducible.
  Protected s.q = $DEADBEEF
  Protected i.i
  Protected u.q
  For i = 0 To #NOISE_SIZE * #NOISE_SIZE - 1
    s = (s * 1664525 + 1013904223) & $FFFFFFFF
    u = s & $FFFFFFFF
    gNoiseTable(i) = u / 4294967296.0   ; map to [0, 1)
  Next
  gNoiseInitialized = 1
EndProcedure

; Sample noise at (x, y) -- float coords, table wraps via 256-mask.
; Returns a value in [0, 1].
Procedure.f NoiseSample(x.f, y.f)
  Protected xi.i = Int(x) & #NOISE_MASK
  Protected yi.i = Int(y) & #NOISE_MASK
  Protected fx.f = x - Int(x)
  Protected fy.f = y - Int(y)
  ; Smoothstep easing for visually pleasant interpolation
  Protected ux.f = fx * fx * (3.0 - 2.0 * fx)
  Protected uy.f = fy * fy * (3.0 - 2.0 * fy)

  Protected x1.i = (xi + 1) & #NOISE_MASK
  Protected y1.i = (yi + 1) & #NOISE_MASK

  Protected n00.f = gNoiseTable(yi * #NOISE_SIZE + xi)
  Protected n10.f = gNoiseTable(yi * #NOISE_SIZE + x1)
  Protected n01.f = gNoiseTable(y1 * #NOISE_SIZE + xi)
  Protected n11.f = gNoiseTable(y1 * #NOISE_SIZE + x1)

  ProcedureReturn n00 + (n10 - n00) * ux + (n01 - n00) * uy + (n11 - n10 - n01 + n00) * ux * uy
EndProcedure

; ============================================================
;  Custom preset INI persistence
; ============================================================

; Resolve the plugin's own folder (where the .nbp lives). Uses
; GetModuleHandleEx_ to find the DLL we're running inside, then
; GetModuleFileName_ to get its full path, then strips the filename.
Procedure.s GetPluginDir()
  If g_pluginDir <> "" : ProcedureReturn g_pluginDir : EndIf

  Protected hMod.i = 0
  ; GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS = 4 -- pass address of our own
  ; procedure so it finds OUR module, not the host process's exe.
  GetModuleHandleEx_(4, @GetPluginDir(), @hMod)
  If hMod = 0 : ProcedureReturn "" : EndIf

  Protected buf.s = Space(1024)
  GetModuleFileName_(hMod, @buf, 1024)
  Protected fullPath.s = PeekS(@buf)
  Protected lastSlash.i = 0
  Protected i.i
  For i = Len(fullPath) To 1 Step -1
    If Mid(fullPath, i, 1) = "\" Or Mid(fullPath, i, 1) = "/"
      lastSlash = i : Break
    EndIf
  Next
  If lastSlash = 0 : ProcedureReturn "" : EndIf

  g_pluginDir = Left(fullPath, lastSlash - 1)
  ProcedureReturn g_pluginDir
EndProcedure

; Full path to the custom-presets INI.
Procedure.s GetPresetsIniPath()
  Protected dir.s = GetPluginDir()
  If dir = "" : ProcedureReturn "" : EndIf
  ProcedureReturn dir + "\NeoParticleAnimator_Presets.ini"
EndProcedure

; Convert a preset name to a safe INI section name. Strips brackets,
; trims, collapses internal whitespace. Empty result means invalid name.
Procedure.s SanitizePresetName(name.s)
  Protected s.s = Trim(name)
  s = ReplaceString(s, "[", "")
  s = ReplaceString(s, "]", "")
  s = ReplaceString(s, "=", "")
  s = ReplaceString(s, Chr(13), "")
  s = ReplaceString(s, Chr(10), "")
  ProcedureReturn s
EndProcedure

; Save an EmitterConfig to the INI under the given preset name.
; Returns 1 on success, 0 on failure.
Procedure.i SaveCustomPreset(name.s, *cfg.EmitterConfig)
  Protected sec.s = SanitizePresetName(name)
  If sec = "" : ProcedureReturn 0 : EndIf
  Protected path.s = GetPresetsIniPath()
  If path = "" : ProcedureReturn 0 : EndIf

  If OpenPreferences(path) = 0
    If CreatePreferences(path) = 0
      ProcedureReturn 0
    EndIf
  EndIf

  PreferenceGroup(sec)
  ; Spawn
  WritePreferenceFloat("spawnRate",    *cfg\spawnRate)
  WritePreferenceInteger("burstCount", *cfg\burstCount)
  ; Position
  WritePreferenceFloat("emitX", *cfg\emitX)
  WritePreferenceFloat("emitY", *cfg\emitY)
  WritePreferenceFloat("emitW", *cfg\emitW)
  WritePreferenceFloat("emitH", *cfg\emitH)
  ; Direction
  WritePreferenceFloat("spreadAngle", *cfg\spreadAngle)
  WritePreferenceFloat("baseAngle",   *cfg\baseAngle)
  ; Speed / Size / Life
  WritePreferenceFloat("speedMin",     *cfg\speedMin)
  WritePreferenceFloat("speedMax",     *cfg\speedMax)
  WritePreferenceFloat("sizeStartMin", *cfg\sizeStartMin)
  WritePreferenceFloat("sizeStartMax", *cfg\sizeStartMax)
  WritePreferenceFloat("sizeEndMin",   *cfg\sizeEndMin)
  WritePreferenceFloat("sizeEndMax",   *cfg\sizeEndMax)
  WritePreferenceFloat("lifeMin",      *cfg\lifeMin)
  WritePreferenceFloat("lifeMax",      *cfg\lifeMax)
  ; Color
  WritePreferenceInteger("colorStartR", *cfg\colorStartR)
  WritePreferenceInteger("colorStartG", *cfg\colorStartG)
  WritePreferenceInteger("colorStartB", *cfg\colorStartB)
  WritePreferenceInteger("colorEndR",   *cfg\colorEndR)
  WritePreferenceInteger("colorEndG",   *cfg\colorEndG)
  WritePreferenceInteger("colorEndB",   *cfg\colorEndB)
  WritePreferenceInteger("hasColorMid", *cfg\hasColorMid)
  WritePreferenceInteger("colorMidR",   *cfg\colorMidR)
  WritePreferenceInteger("colorMidG",   *cfg\colorMidG)
  WritePreferenceInteger("colorMidB",   *cfg\colorMidB)
  WritePreferenceFloat("colorMidPos",   *cfg\colorMidPos)
  WritePreferenceInteger("colorOverride", *cfg\colorOverride)
  ; Physics
  WritePreferenceFloat("gravity",         *cfg\gravity)
  WritePreferenceFloat("drag",            *cfg\drag)
  WritePreferenceFloat("windX",           *cfg\windX)
  WritePreferenceFloat("windY",           *cfg\windY)
  WritePreferenceFloat("gravityVariance", *cfg\gravityVariance)
  WritePreferenceFloat("noisePower",      *cfg\noisePower)
  WritePreferenceFloat("noiseScale",      *cfg\noiseScale)
  WritePreferenceFloat("noiseSpeed",      *cfg\noiseSpeed)
  ; Alpha
  WritePreferenceFloat("alphaStart",       *cfg\alphaStart)
  WritePreferenceFloat("alphaEnd",         *cfg\alphaEnd)
  WritePreferenceFloat("fadeInFraction",   *cfg\fadeInFraction)
  WritePreferenceFloat("fadeOutFraction",  *cfg\fadeOutFraction)
  ; Rendering
  WritePreferenceFloat("glowSoftness",  *cfg\glowSoftness)
  WritePreferenceInteger("additiveBlend", *cfg\additiveBlend)
  WritePreferenceFloat("feather",       *cfg\feather)
  WritePreferenceFloat("stretch",       *cfg\stretch)
  WritePreferenceInteger("shape",       *cfg\shape)
  ; Modulation
  WritePreferenceFloat("twinkleSpeed",  *cfg\twinkleSpeed)
  WritePreferenceFloat("twinkleAmount", *cfg\twinkleAmount)
  WritePreferenceFloat("pulseSpeed",    *cfg\pulseSpeed)
  WritePreferenceFloat("pulseAmount",   *cfg\pulseAmount)
  WritePreferenceFloat("beamCore",      *cfg\beamCore)
  WritePreferenceFloat("beamHalo",      *cfg\beamHalo)
  WritePreferenceFloat("beamEndGlow",   *cfg\beamEndGlow)

  ClosePreferences()
  ProcedureReturn 1
EndProcedure

; Load an EmitterConfig from the INI section. Returns 1 if section found
; and loaded, 0 if no such custom preset exists.
Procedure.i LoadCustomPreset(name.s, *cfg.EmitterConfig)
  ; Look up the in-memory cache populated by RefreshCustomPresetList.
  ; No file I/O here -- avoids PB preferences engine state issues that
  ; caused repeated calls to silently read defaults instead of saved values.
  Protected sec.s = LCase(SanitizePresetName(name))
  If sec = "" : ProcedureReturn 0 : EndIf
  If FindMapElement(g_customPresetCache(), sec) = 0
    ProcedureReturn 0
  EndIf
  ; Copy the cached config into the caller's struct.
  CopyMemory(@g_customPresetCache(), *cfg, SizeOf(EmitterConfig))
  ProcedureReturn 1
EndProcedure

; Delete a custom preset section. Returns 1 if deleted, 0 if not found.
Procedure.i DeleteCustomPreset(name.s)
  Protected sec.s = SanitizePresetName(name)
  If sec = "" : ProcedureReturn 0 : EndIf
  Protected path.s = GetPresetsIniPath()
  If path = "" : ProcedureReturn 0 : EndIf
  If OpenPreferences(path) = 0 : ProcedureReturn 0 : EndIf
  Protected ok.i = RemovePreferenceGroup(sec)
  ClosePreferences()
  ProcedureReturn ok
EndProcedure

; Rebuilds g_customPresetNames from the INI file.
; Set a single field on an EmitterConfig from a key=value pair, parsed from
; the raw INI line. Avoids the PB preferences engine entirely.
Procedure SetCustomPresetField(*cfg.EmitterConfig, key.s, value.s)
  Select LCase(key)
    Case "spawnrate"       : *cfg\spawnRate = ValF(value)
    Case "burstcount"      : *cfg\burstCount = Val(value)
    Case "emitx"           : *cfg\emitX = ValF(value)
    Case "emity"           : *cfg\emitY = ValF(value)
    Case "emitw"           : *cfg\emitW = ValF(value)
    Case "emith"           : *cfg\emitH = ValF(value)
    Case "spreadangle"     : *cfg\spreadAngle = ValF(value)
    Case "baseangle"       : *cfg\baseAngle = ValF(value)
    Case "speedmin"        : *cfg\speedMin = ValF(value)
    Case "speedmax"        : *cfg\speedMax = ValF(value)
    Case "sizestartmin"    : *cfg\sizeStartMin = ValF(value)
    Case "sizestartmax"    : *cfg\sizeStartMax = ValF(value)
    Case "sizeendmin"      : *cfg\sizeEndMin = ValF(value)
    Case "sizeendmax"      : *cfg\sizeEndMax = ValF(value)
    Case "lifemin"         : *cfg\lifeMin = ValF(value)
    Case "lifemax"         : *cfg\lifeMax = ValF(value)
    Case "colorstartr"     : *cfg\colorStartR = Val(value)
    Case "colorstartg"     : *cfg\colorStartG = Val(value)
    Case "colorstartb"     : *cfg\colorStartB = Val(value)
    Case "colorendr"       : *cfg\colorEndR = Val(value)
    Case "colorendg"       : *cfg\colorEndG = Val(value)
    Case "colorendb"       : *cfg\colorEndB = Val(value)
    Case "hascolormid"     : *cfg\hasColorMid = Val(value)
    Case "colormidr"       : *cfg\colorMidR = Val(value)
    Case "colormidg"       : *cfg\colorMidG = Val(value)
    Case "colormidb"       : *cfg\colorMidB = Val(value)
    Case "colormidpos"     : *cfg\colorMidPos = ValF(value)
    Case "coloroverride"   : *cfg\colorOverride = Val(value)
    Case "gravity"         : *cfg\gravity = ValF(value)
    Case "drag"            : *cfg\drag = ValF(value)
    Case "windx"           : *cfg\windX = ValF(value)
    Case "windy"           : *cfg\windY = ValF(value)
    Case "gravityvariance" : *cfg\gravityVariance = ValF(value)
    Case "noisepower"      : *cfg\noisePower = ValF(value)
    Case "noisescale"      : *cfg\noiseScale = ValF(value)
    Case "noisespeed"      : *cfg\noiseSpeed = ValF(value)
    Case "alphastart"      : *cfg\alphaStart = ValF(value)
    Case "alphaend"        : *cfg\alphaEnd = ValF(value)
    Case "fadeinfraction"  : *cfg\fadeInFraction = ValF(value)
    Case "fadeoutfraction" : *cfg\fadeOutFraction = ValF(value)
    Case "glowsoftness"    : *cfg\glowSoftness = ValF(value)
    Case "additiveblend"   : *cfg\additiveBlend = Val(value)
    Case "feather"         : *cfg\feather = ValF(value)
    Case "stretch"         : *cfg\stretch = ValF(value)
    Case "shape"           : *cfg\shape = Val(value)
    Case "twinklespeed"    : *cfg\twinkleSpeed = ValF(value)
    Case "twinkleamount"   : *cfg\twinkleAmount = ValF(value)
    Case "pulsespeed"      : *cfg\pulseSpeed = ValF(value)
    Case "pulseamount"     : *cfg\pulseAmount = ValF(value)
    Case "beamcore"        : *cfg\beamCore = ValF(value)
    Case "beamhalo"        : *cfg\beamHalo = ValF(value)
    Case "beamendglow"     : *cfg\beamEndGlow = ValF(value)
  EndSelect
EndProcedure

; Rebuilds both g_customPresetNames and g_customPresetCache by parsing the
; INI file as raw text. We do this manually instead of using PB's
; preferences engine because the latter has a state issue that caused
; subsequent reads in the same session to return default values silently.
;
; INI format: lines starting with '[' are section headers (preset names),
; lines containing '=' are key-value pairs.
Procedure RefreshCustomPresetList()
  ClearList(g_customPresetNames())
  ClearMap(g_customPresetCache())

  Protected path.s = GetPresetsIniPath()
  If path = "" : ProcedureReturn : EndIf

  Protected fh.i = ReadFile(#PB_Any, path)
  If fh = 0 : ProcedureReturn : EndIf

  Protected currentSec.s = ""
  Protected *curCfg.EmitterConfig = 0
  Protected line.s, trimmed.s
  Protected eqPos.i, key.s, value.s

  While Eof(fh) = 0
    line = ReadString(fh)
    trimmed = Trim(line)
    If trimmed = "" : Continue : EndIf
    If Left(trimmed, 1) = ";" Or Left(trimmed, 1) = "#" : Continue : EndIf

    ; Section header [Name]
    If Left(trimmed, 1) = "[" And Right(trimmed, 1) = "]"
      currentSec = Trim(Mid(trimmed, 2, Len(trimmed) - 2))
      If currentSec <> ""
        AddElement(g_customPresetNames())
        g_customPresetNames() = currentSec
        ; Allocate a cache slot for this preset
        AddMapElement(g_customPresetCache(), LCase(currentSec))
        *curCfg = @g_customPresetCache()
      Else
        *curCfg = 0
      EndIf
      Continue
    EndIf

    ; Key=value
    If *curCfg = 0 : Continue : EndIf
    eqPos = FindString(trimmed, "=", 1)
    If eqPos = 0 : Continue : EndIf
    key = Trim(Left(trimmed, eqPos - 1))
    value = Trim(Mid(trimmed, eqPos + 1))
    SetCustomPresetField(*curCfg, key, value)
  Wend

  CloseFile(fh)
EndProcedure

; ROLLED BACK: custom preset loading is currently disabled while we debug
; a state issue that caused subsequent Plays after Stop to render incorrectly.
; The feature infrastructure is preserved so it can be re-enabled cleanly
; once the underlying issue is identified -- just remove the early return
; below to restore normal behaviour.
Procedure.i IsCustomPreset(name.s)
  ProcedureReturn 0
  ; --- original code preserved below for re-enable ---
  Protected sec.s = LCase(SanitizePresetName(name))
  ForEach g_customPresetNames()
    If LCase(g_customPresetNames()) = sec
      ProcedureReturn 1
    EndIf
  Next
  ProcedureReturn 0
EndProcedure

Procedure.i EnsureGDIPlus()
  If g_gdiplusStarted : ProcedureReturn 1 : EndIf
  Protected si.GdiplusStartupInput
  si\GdiplusVersion = 1
  If GdiplusStartup(@g_gdiplusToken, @si, 0) = 0
    g_gdiplusStarted = 1
    ; Initialize noise table on first GDI+ init -- it's idempotent so this
    ; only does the actual work once. Cheap to call, expensive to use cold.
    InitNoiseTable()
    ; Load custom presets list (idempotent; safe to re-invoke later)
    RefreshCustomPresetList()
    ProcedureReturn 1
  EndIf
  ProcedureReturn 0
EndProcedure

; ============================================================
;  Sprite (GDI+ image) load / release helpers
; ============================================================

; Disposes the emitter's currently-loaded sprite (if any).
Procedure ReleaseEmitterSprite(*em.ParticleEmitter)
  If *em\hSpriteImage
    GdipDisposeImage(*em\hSpriteImage)
    *em\hSpriteImage = 0
  EndIf
  *em\spriteW = 0
  *em\spriteH = 0
  *em\spritePath = ""
EndProcedure

; Load a PNG/JPG/BMP from disk into the emitter's sprite slot.
; Idempotent: if path matches the already-loaded sprite, no-op.
; On failure, the emitter ends up with hSpriteImage=0 (renderer skips).
Procedure.i LoadEmitterSprite(*em.ParticleEmitter, path.s)
  If path = ""
    ReleaseEmitterSprite(*em)
    ProcedureReturn 0
  EndIf

  ; Skip if already loaded with this exact path
  If *em\hSpriteImage And *em\spritePath = path
    ProcedureReturn 1
  EndIf

  ReleaseEmitterSprite(*em)

  If EnsureGDIPlus() = 0 : ProcedureReturn 0 : EndIf

  ; GdipLoadImageFromFile takes a wide (UTF-16) string. The rest of the
  ; codebase uses ASCII for VNW interop, so we convert just for this call.
  Protected wideLen.i = (Len(path) + 1) * 2
  Protected *widePath = AllocateMemory(wideLen)
  If *widePath = 0 : ProcedureReturn 0 : EndIf
  PokeS(*widePath, path, -1, #PB_Unicode)

  Protected hImage.i = 0
  Protected loadResult.i = GdipLoadImageFromFile(*widePath, @hImage)
  FreeMemory(*widePath)

  If loadResult <> 0 Or hImage = 0
    SetLastError("Failed to load sprite: '" + path + "'")
    ProcedureReturn 0
  EndIf

  Protected wOut.l = 0, hOut.l = 0
  GdipGetImageWidth(hImage, @wOut)
  GdipGetImageHeight(hImage, @hOut)

  ; Reject absurdly large sprites -- a 6K+ texture rendered at 50+ particles
  ; per frame with bilinear scaling will choke any GDI+ pipeline. 4096 covers
  ; common cloud/smoke textures (often 2K or 4K) while protecting against
  ; truly outlandish ones.
  If wOut > 4096 Or hOut > 4096
    GdipDisposeImage(hImage)
    SetLastError("Sprite too large (" + Str(wOut) + "x" + Str(hOut) +
                 "). Maximum 4096x4096; please resize: '" + path + "'")
    ProcedureReturn 0
  EndIf

  ; Pre-resize sprite to a working resolution for fast per-frame rendering.
  ; A 4K cloud sprite scaled to ~100px every frame is enormously wasteful;
  ; doing the heavy resize once at load with HQ interpolation, then sampling
  ; from a 512px working image at draw time, is 10-30x faster per frame.
  Protected origW.l = wOut, origH.l = hOut
  Protected wasResized.i = 0
  Protected maxDim.l = 512
  If wOut > maxDim Or hOut > maxDim
    Protected scale.f
    If wOut >= hOut
      scale = maxDim / wOut
    Else
      scale = maxDim / hOut
    EndIf
    Protected newW.l = Round(wOut * scale, #PB_Round_Nearest)
    Protected newH.l = Round(hOut * scale, #PB_Round_Nearest)
    If newW < 1 : newW = 1 : EndIf
    If newH < 1 : newH = 1 : EndIf

    Protected hResized.i = 0
    ; PixelFormat32bppPARGB = 0xE200B (premultiplied alpha matches the
    ; layered-window output format, avoiding extra conversion at draw time).
    Protected createRes.i = GdipCreateBitmapFromScan0(newW, newH, 0, $E200B, 0, @hResized)
    If createRes = 0 And hResized <> 0
      Protected hRGfx.i = 0
      GdipGetImageGraphicsContext(hResized, @hRGfx)
      If hRGfx
        ; HQ bicubic for the one-time resize -- this is the only place
        ; we pay for high-quality scaling, so make it count.
        GdipSetInterpolationMode(hRGfx, #InterpolationModeHighQualityBicubic)
        GdipDrawImageRectI(hRGfx, hImage, 0, 0, newW, newH)
        GdipDeleteGraphics(hRGfx)
        GdipDisposeImage(hImage)
        hImage = hResized
        wOut = newW
        hOut = newH
        wasResized = 1
      Else
        GdipDisposeImage(hResized)
      EndIf
    EndIf
  EndIf

  *em\hSpriteImage = hImage
  *em\spriteW = wOut
  *em\spriteH = hOut
  *em\spritePath = path
  If wasResized
    SetLastError("Loaded sprite: " + path +
                 " (resized " + Str(origW) + "x" + Str(origH) +
                 " -> " + Str(wOut) + "x" + Str(hOut) + " for performance)")
  Else
    SetLastError("Loaded sprite: " + path + " (" + Str(wOut) + "x" + Str(hOut) + ")")
  EndIf
  ProcedureReturn 1
EndProcedure

; ============================================================
;  Math helpers
; ============================================================

Procedure.f RandF(minVal.f, maxVal.f)
  If maxVal <= minVal : ProcedureReturn minVal : EndIf
  ProcedureReturn minVal + (maxVal - minVal) * (Random(10000) / 10000.0)
EndProcedure

Procedure.i ClampI(val.i, lo.i, hi.i)
  If val < lo : ProcedureReturn lo : EndIf
  If val > hi : ProcedureReturn hi : EndIf
  ProcedureReturn val
EndProcedure

Procedure.f ClampF(val.f, lo.f, hi.f)
  If val < lo : ProcedureReturn lo : EndIf
  If val > hi : ProcedureReturn hi : EndIf
  ProcedureReturn val
EndProcedure

Procedure.f LerpF(a.f, b.f, t.f)
  ProcedureReturn a + (b - a) * t
EndProcedure

Procedure.i LerpI(a.i, b.i, t.f)
  ProcedureReturn a + Round((b - a) * t, #PB_Round_Nearest)
EndProcedure

; Returns the angle (in degrees) of velocity vector (vx, vy), measured from
; the positive x-axis with 0=right, 90=down, 180=left, 270/-90=up.
; Used by velocity-aligned shapes (RAY, BEAM, LASER) to orient particles.
;
; This is a manual implementation rather than using ATan2 because PureBasic's
; ATan2 documentation describes it as "arctangent of x/y" without making its
; quadrant behaviour explicit; the bare call returns wrong angles when vx is
; exactly 0 (e.g. perfectly-vertical rain with spreadAngle=0) on some PB
; versions. Using single-arg ATan + manual quadrant handling is reliable.
Procedure.f VelocityAngleDeg(vx.f, vy.f)
  If Abs(vx) < 0.000001
    If vy >= 0.0
      ProcedureReturn 90.0   ; straight down
    Else
      ProcedureReturn -90.0  ; straight up
    EndIf
  EndIf
  Protected angleDeg.f = ATan(vy / vx) * 180.0 / #PI
  If vx < 0.0
    angleDeg + 180.0
  EndIf
  ProcedureReturn angleDeg
EndProcedure

; ============================================================
;  Color helpers
; ============================================================
Procedure.i RGBtoCOLORREF(r.i, g.i, b.i)
  ProcedureReturn (b << 16) | (g << 8) | r
EndProcedure

Procedure.i ExtractRed(color.l)
  ProcedureReturn color & $FF
EndProcedure

Procedure.i ExtractGreen(color.l)
  ProcedureReturn (color >> 8) & $FF
EndProcedure

Procedure.i ExtractBlue(color.l)
  ProcedureReturn (color >> 16) & $FF
EndProcedure

; Parse "R,G,B" string. Returns 1 on success and writes r/g/b through pointers.
Procedure.i ParseColorString(s.s, *outR.Long, *outG.Long, *outB.Long)
  If s = "" : ProcedureReturn 0 : EndIf
  Protected sR.s = Trim(StringField(s, 1, ","))
  Protected sG.s = Trim(StringField(s, 2, ","))
  Protected sB.s = Trim(StringField(s, 3, ","))
  If sR = "" Or sG = "" Or sB = "" : ProcedureReturn 0 : EndIf
  PokeL(*outR, ClampI(Val(sR), 0, 255))
  PokeL(*outG, ClampI(Val(sG), 0, 255))
  PokeL(*outB, ClampI(Val(sB), 0, 255))
  ProcedureReturn 1
EndProcedure

; ============================================================
;  Preset configurations (14 presets, identical to original engine)
; ============================================================

Procedure ApplyPreset(presetName.s, *cfg.EmitterConfig)
  Protected name.s = LCase(Trim(presetName))

  ; Custom preset override: if a custom INI section matches this name,
  ; load it and return. This means saving a preset named "Fire" overrides
  ; the built-in Fire. Deleting the custom preset (via "Reset to Default"
  ; button in the dialog) restores the built-in behaviour.
  If IsCustomPreset(presetName)
    If LoadCustomPreset(presetName, *cfg)
      ProcedureReturn
    EndIf
  EndIf

  ; Common defaults
  *cfg\emitX = 0.5 : *cfg\emitY = 0.5
  *cfg\emitW = 0.0 : *cfg\emitH = 0.0
  *cfg\drag = 1.0
  *cfg\fadeInFraction = 0.1
  *cfg\fadeOutFraction = 0.0   ; default: no fade-out (alpha lerp handles it)
  *cfg\glowSoftness = 0.8
  *cfg\additiveBlend = 0
  *cfg\burstCount = 0
  *cfg\feather = 0.0
  *cfg\taper = 0.0    ; 0.0 = no taper (rectangle), 1.0 = full taper (point)
  *cfg\taperEdge = 0  ; 0=top narrow, 1=bottom narrow, 2=left narrow, 3=right narrow
  *cfg\stretch = 1.0
  *cfg\shape = #SHAPE_CIRCLE
  *cfg\twinkleSpeed = 0.0
  *cfg\twinkleAmount = 0.0
  *cfg\pulseSpeed = 0.0
  *cfg\pulseAmount = 0.0
  *cfg\beamCore = 1.0
  ; New fields (Fire-fix sprint): default to disabled/neutral
  *cfg\hasColorMid = 0
  *cfg\colorMidPos = 0.5
  *cfg\windX = 0.0
  *cfg\windY = 0.0
  *cfg\gravityVariance = 0.0
  *cfg\noisePower = 0.0
  *cfg\noiseScale = 100.0
  *cfg\noiseSpeed = 1.0
  *cfg\beamHalo = 0.6
  *cfg\beamEndGlow = 0.5
  *cfg\burstRays = 12
  *cfg\burstSpread = 360.0
  *cfg\burstWidth = 2.0
  *cfg\colorOverride = 0

  Select name

    Case "sparkle"
      *cfg\spawnRate = 40.0
      *cfg\spreadAngle = 360.0 : *cfg\baseAngle = 270.0
      *cfg\speedMin = 20.0 : *cfg\speedMax = 80.0
      *cfg\sizeStartMin = 2.0 : *cfg\sizeStartMax = 5.0
      *cfg\sizeEndMin = 0.5 : *cfg\sizeEndMax = 1.0
      *cfg\lifeMin = 0.5 : *cfg\lifeMax = 1.5
      *cfg\colorStartR = 255 : *cfg\colorStartG = 255 : *cfg\colorStartB = 200
      *cfg\colorEndR = 255 : *cfg\colorEndG = 200 : *cfg\colorEndB = 100
      *cfg\gravity = 15.0
      *cfg\alphaStart = 1.0 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 1
      *cfg\glowSoftness = 1.0

    Case "fire"
      ; Fire-fix sprint upgrade: leverages 3-stop color gradient (yellow ->
      ; orange -> dark red, the hot-cool-cooler arc real flame takes) plus
      ; Perlin noise turbulence (organic dancing motion) plus gravity
      ; variance (natural per-particle rise variation). These three together
      ; close most of the gap between the HTML reference and PB rendering.
      *cfg\shape = #SHAPE_RAY
      *cfg\spawnRate = 60.0
      *cfg\emitY = 0.88 : *cfg\emitW = 0.16 : *cfg\emitH = 0.04
      *cfg\spreadAngle = 8.0 : *cfg\baseAngle = 270.0
      *cfg\speedMin = 90.0 : *cfg\speedMax = 140.0
      *cfg\sizeStartMin = 4.0 : *cfg\sizeStartMax = 20.0
      *cfg\sizeEndMin = 1.5 : *cfg\sizeEndMax = 3.5
      *cfg\lifeMin = 0.5 : *cfg\lifeMax = 1.1
      ; 3-stop color: hot yellow-white core -> orange mid -> dark red tail
      *cfg\colorStartR = 255 : *cfg\colorStartG = 230 : *cfg\colorStartB = 100
      *cfg\hasColorMid = 1 : *cfg\colorMidPos = 0.45
      *cfg\colorMidR = 255 : *cfg\colorMidG = 110 : *cfg\colorMidB = 25
      *cfg\colorEndR = 80  : *cfg\colorEndG = 15  : *cfg\colorEndB = 0
      *cfg\gravity = -60.0
      *cfg\gravityVariance = 0.3   ; +/- 30% per-particle gravity for variation
      *cfg\drag = 0.97
      ; Perlin noise: gentle sideways turbulence -- gives flame the
      ; organic dancing motion that hardcoded sine wobble can't replicate.
      *cfg\noisePower = 65.0
      *cfg\noiseScale = 40.0
      *cfg\noiseSpeed = 2.2
      *cfg\windX = 4.0      ; tiny rightward breeze (looks more natural)
      *cfg\alphaStart = 0.9 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 1
      *cfg\glowSoftness = 1.3
      *cfg\fadeInFraction = 0.1
      *cfg\stretch = 3.0
      *cfg\beamCore = 1.0
      *cfg\beamHalo = 0.6
      *cfg\beamEndGlow = 0.4
      *cfg\feather = 0.15   ; soft edge fade so rect outline doesn't show

    Case "rain"
      *cfg\spawnRate = 120.0
      *cfg\emitY = 0.0 : *cfg\emitW = 1.0 : *cfg\emitH = 0.0
      ; Straight down, no spread -- all streaks parallel.
      *cfg\spreadAngle = 0.0 : *cfg\baseAngle = 90.0
      *cfg\speedMin = 350.0 : *cfg\speedMax = 500.0
      ; Slight thickness variation so streaks don't look mechanically uniform.
      *cfg\sizeStartMin = 1.5 : *cfg\sizeStartMax = 3.0
      *cfg\sizeEndMin = 1.5 : *cfg\sizeEndMax = 3.0
      *cfg\lifeMin = 0.5 : *cfg\lifeMax = 1.0
      *cfg\colorStartR = 170 : *cfg\colorStartG = 200 : *cfg\colorStartB = 240
      *cfg\colorEndR = 130 : *cfg\colorEndG = 170 : *cfg\colorEndB = 220
      *cfg\gravity = 200.0
      *cfg\alphaStart = 0.65 : *cfg\alphaEnd = 0.1
      *cfg\additiveBlend = 0
      *cfg\glowSoftness = 0.0
      *cfg\fadeInFraction = 0.0
      ; Laser shape orients to velocity; with vx=0 it draws as a vertical line.
      ; Halo at 0 = clean line streak (no glow); core stays full.
      *cfg\shape = #SHAPE_LASER
      *cfg\stretch = 5.0
      *cfg\beamCore = 1.0
      *cfg\beamHalo = 0.0

    Case "snow"
      *cfg\spawnRate = 60.0
      *cfg\emitY = 0.0 : *cfg\emitW = 1.1 : *cfg\emitH = 0.0
      *cfg\spreadAngle = 30.0 : *cfg\baseAngle = 90.0
      *cfg\speedMin = 20.0 : *cfg\speedMax = 60.0
      ; Larger flakes -- more visible against any background. Tight range
      ; (4-7, ~1.75x ratio) keeps individual flakes consistent so size
      ; scaling doesn't produce outliers that look like flash blobs.
      *cfg\sizeStartMin = 4.0 : *cfg\sizeStartMax = 7.0
      *cfg\sizeEndMin = 4.0 : *cfg\sizeEndMax = 7.0
      *cfg\lifeMin = 3.0 : *cfg\lifeMax = 6.0
      *cfg\colorStartR = 250 : *cfg\colorStartG = 252 : *cfg\colorStartB = 255
      *cfg\colorEndR = 220 : *cfg\colorEndG = 230 : *cfg\colorEndB = 245
      *cfg\gravity = 8.0
      *cfg\drag = 0.99
      ; Higher alpha (0.7) makes individual flakes clearly visible.
      ; With normal (non-additive) blending and lower spawn rate (60),
      ; overlap clusters don't reach over-saturation.
      *cfg\alphaStart = 0.7 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 0
      *cfg\glowSoftness = 0.6
      *cfg\fadeInFraction = 0.15

    Case "confetti"
      *cfg\spawnRate = 70.0
      *cfg\emitY = 0.0 : *cfg\emitW = 1.0 : *cfg\emitH = 0.0
      *cfg\spreadAngle = 60.0 : *cfg\baseAngle = 90.0
      *cfg\speedMin = 30.0 : *cfg\speedMax = 100.0
      ; Smaller pieces -- real confetti is small bits, not paint splats.
      *cfg\sizeStartMin = 2.5 : *cfg\sizeStartMax = 4.0
      *cfg\sizeEndMin = 2.5 : *cfg\sizeEndMax = 4.0
      *cfg\lifeMin = 2.0 : *cfg\lifeMax = 4.0
      *cfg\colorStartR = 255 : *cfg\colorStartG = 100 : *cfg\colorStartB = 100
      *cfg\colorEndR = 100 : *cfg\colorEndG = 100 : *cfg\colorEndB = 255
      *cfg\gravity = 40.0
      *cfg\drag = 0.98
      *cfg\alphaStart = 1.0 : *cfg\alphaEnd = 0.3
      *cfg\additiveBlend = 0
      *cfg\glowSoftness = 0.0
      *cfg\fadeInFraction = 0.0
      *cfg\shape = #SHAPE_RECT
      *cfg\stretch = 2.2

    Case "magicdust"
      *cfg\spawnRate = 50.0
      *cfg\emitW = 0.6 : *cfg\emitH = 0.6
      *cfg\spreadAngle = 360.0 : *cfg\baseAngle = 0.0
      *cfg\speedMin = 5.0 : *cfg\speedMax = 30.0
      *cfg\sizeStartMin = 1.0 : *cfg\sizeStartMax = 4.0
      *cfg\sizeEndMin = 0.5 : *cfg\sizeEndMax = 2.0
      *cfg\lifeMin = 0.8 : *cfg\lifeMax = 2.5
      *cfg\colorStartR = 180 : *cfg\colorStartG = 130 : *cfg\colorStartB = 255
      *cfg\colorEndR = 100 : *cfg\colorEndG = 200 : *cfg\colorEndB = 255
      *cfg\gravity = -5.0
      *cfg\drag = 0.98
      *cfg\alphaStart = 0.9 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 1
      *cfg\glowSoftness = 1.0
      *cfg\fadeInFraction = 0.15

    Case "explosion"
      *cfg\spawnRate = 0.0
      *cfg\burstCount = 200
      *cfg\spreadAngle = 360.0 : *cfg\baseAngle = 0.0
      *cfg\speedMin = 80.0 : *cfg\speedMax = 350.0
      *cfg\sizeStartMin = 4.0 : *cfg\sizeStartMax = 10.0
      *cfg\sizeEndMin = 1.0 : *cfg\sizeEndMax = 3.0
      *cfg\lifeMin = 0.3 : *cfg\lifeMax = 1.5
      *cfg\colorStartR = 255 : *cfg\colorStartG = 220 : *cfg\colorStartB = 80
      *cfg\colorEndR = 200 : *cfg\colorEndG = 60 : *cfg\colorEndB = 20
      *cfg\gravity = 60.0
      *cfg\drag = 0.95
      *cfg\alphaStart = 1.0 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 1
      *cfg\glowSoftness = 0.9
      *cfg\fadeInFraction = 0.0

    Case "lightrays"
      *cfg\shape = #SHAPE_RAY
      *cfg\spawnRate = 8.0
      *cfg\emitX = 0.5 : *cfg\emitY = 0.0
      *cfg\emitW = 0.6 : *cfg\emitH = 0.05
      *cfg\spreadAngle = 40.0 : *cfg\baseAngle = 90.0
      *cfg\speedMin = 30.0 : *cfg\speedMax = 80.0
      *cfg\sizeStartMin = 20.0 : *cfg\sizeStartMax = 50.0
      *cfg\sizeEndMin = 10.0 : *cfg\sizeEndMax = 30.0
      *cfg\lifeMin = 1.5 : *cfg\lifeMax = 3.5
      *cfg\colorStartR = 255 : *cfg\colorStartG = 245 : *cfg\colorStartB = 200
      *cfg\colorEndR = 255 : *cfg\colorEndG = 220 : *cfg\colorEndB = 150
      *cfg\gravity = 0.0
      *cfg\drag = 0.99
      *cfg\alphaStart = 0.5 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 1
      *cfg\glowSoftness = 0.9
      *cfg\fadeInFraction = 0.15
      *cfg\stretch = 6.0

    Case "beams"
      *cfg\shape = #SHAPE_BEAM
      *cfg\spawnRate = 5.0
      *cfg\emitX = 0.5 : *cfg\emitY = 0.0
      *cfg\emitW = 0.8 : *cfg\emitH = 0.02
      *cfg\spreadAngle = 20.0 : *cfg\baseAngle = 90.0
      *cfg\speedMin = 15.0 : *cfg\speedMax = 40.0
      *cfg\sizeStartMin = 30.0 : *cfg\sizeStartMax = 60.0
      *cfg\sizeEndMin = 15.0 : *cfg\sizeEndMax = 40.0
      *cfg\lifeMin = 2.0 : *cfg\lifeMax = 4.0
      *cfg\colorStartR = 255 : *cfg\colorStartG = 250 : *cfg\colorStartB = 220
      *cfg\colorEndR = 255 : *cfg\colorEndG = 240 : *cfg\colorEndB = 180
      *cfg\gravity = 0.0
      *cfg\drag = 0.98
      *cfg\alphaStart = 0.35 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 1
      *cfg\glowSoftness = 1.0
      *cfg\fadeInFraction = 0.2
      *cfg\stretch = 10.0

    Case "glow"
      *cfg\shape = #SHAPE_GLOW
      *cfg\spawnRate = 6.0
      *cfg\emitX = 0.5 : *cfg\emitY = 0.5
      *cfg\emitW = 0.6 : *cfg\emitH = 0.6
      *cfg\spreadAngle = 360.0 : *cfg\baseAngle = 0.0
      *cfg\speedMin = 3.0 : *cfg\speedMax = 12.0
      *cfg\sizeStartMin = 30.0 : *cfg\sizeStartMax = 70.0
      *cfg\sizeEndMin = 40.0 : *cfg\sizeEndMax = 90.0
      *cfg\lifeMin = 2.0 : *cfg\lifeMax = 5.0
      *cfg\colorStartR = 255 : *cfg\colorStartG = 230 : *cfg\colorStartB = 180
      *cfg\colorEndR = 200 : *cfg\colorEndG = 180 : *cfg\colorEndB = 255
      *cfg\gravity = 0.0
      *cfg\drag = 0.99
      *cfg\alphaStart = 0.4 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 1
      *cfg\glowSoftness = 1.0
      *cfg\fadeInFraction = 0.2
      *cfg\fadeOutFraction = 0.3
      *cfg\twinkleSpeed = 1.5
      *cfg\twinkleAmount = 0.3

    Case "stars"
      *cfg\shape = #SHAPE_STAR
      *cfg\spawnRate = 12.0
      *cfg\emitX = 0.5 : *cfg\emitY = 0.5
      *cfg\emitW = 0.95 : *cfg\emitH = 0.95
      *cfg\spreadAngle = 360.0 : *cfg\baseAngle = 0.0
      ; Stars don't drift -- they twinkle in place. Speed is forced to 0 so
      ; the velocity computed in SpawnParticle ends up vx=vy=0.
      *cfg\speedMin = 0.0 : *cfg\speedMax = 0.0
      *cfg\sizeStartMin = 2.0 : *cfg\sizeStartMax = 6.0
      *cfg\sizeEndMin = 1.0 : *cfg\sizeEndMax = 4.0
      *cfg\lifeMin = 2.0 : *cfg\lifeMax = 6.0
      *cfg\colorStartR = 255 : *cfg\colorStartG = 255 : *cfg\colorStartB = 240
      *cfg\colorEndR = 200 : *cfg\colorEndG = 220 : *cfg\colorEndB = 255
      *cfg\gravity = 0.0
      *cfg\drag = 1.0
      *cfg\alphaStart = 0.9 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 1
      *cfg\glowSoftness = 1.0
      *cfg\fadeInFraction = 0.1
      *cfg\twinkleSpeed = 3.0
      *cfg\twinkleAmount = 0.7

    Case "lightbeams"
      ; Volumetric light beams: tall stationary columns of soft light,
      ; like sunbeams through a window, theatrical spotlights, or
      ; headlight beams in fog. Key design principles:
      ;   - Particles are STATIONARY (heavy drag + low speed) so beams
      ;     are spatial features, not flowing streams
      ;   - LARGE size + high stretch = each particle is a tall column
      ;   - Soft additive blend + high glowSoftness = volumetric appearance
      ;     where overlapping particles build up to bright beams
      ;   - Tight spread = beams stay roughly parallel
      ;   - Long life + moderate spawn = stable population of beams
      ;     that gently shift through brightness
      *cfg\shape = #SHAPE_BEAM
      *cfg\spawnRate = 12.0
      *cfg\emitX = 0.5 : *cfg\emitY = 0.5
      *cfg\emitW = 1.0 : *cfg\emitH = 1.0
      *cfg\spreadAngle = 8.0 : *cfg\baseAngle = 270.0  ; tight parallel cone
      *cfg\speedMin = 2.0 : *cfg\speedMax = 5.0        ; minimal motion
      *cfg\sizeStartMin = 14.0 : *cfg\sizeStartMax = 22.0  ; large = wide beams
      *cfg\sizeEndMin = 14.0 : *cfg\sizeEndMax = 22.0      ; no size change
      *cfg\lifeMin = 4.0 : *cfg\lifeMax = 7.0              ; long-lived
      ; Warm pale gold -- works for sunbeams, spotlights, dust-in-light.
      ; Override colorStart/colorEnd for cool moonbeams (240,245,255) or
      ; theatrical colors (concert spotlights, etc).
      *cfg\colorStartR = 255 : *cfg\colorStartG = 245 : *cfg\colorStartB = 210
      *cfg\colorEndR   = 220 : *cfg\colorEndG   = 220 : *cfg\colorEndB   = 200
      *cfg\gravity = 0.0
      *cfg\drag = 0.85           ; heavy drag -- particles freeze in place
      *cfg\alphaStart = 0.35 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 1
      *cfg\glowSoftness = 1.7    ; very soft edges for volumetric blending
      *cfg\fadeInFraction = 0.3
      *cfg\fadeOutFraction = 0.4
      *cfg\stretch = 10.0        ; very long beams
      *cfg\feather = 0.15
      *cfg\beamCore = 1.0
      *cfg\beamHalo = 0.7        ; strong halo for that "godlike" glow
      *cfg\beamEndGlow = 0.4
      ; Gentle internal shimmer -- like dust motes catching light
      *cfg\pulseSpeed = 0.5
      *cfg\pulseAmount = 0.2

    Case "godrays"
      *cfg\spawnRate = 10.0
      *cfg\spreadAngle = 360.0 : *cfg\baseAngle = 0.0
      *cfg\speedMin = 0.0 : *cfg\speedMax = 5.0
      *cfg\sizeStartMin = 12.0 : *cfg\sizeStartMax = 20.0
      *cfg\sizeEndMin = 10.0 : *cfg\sizeEndMax = 16.0
      *cfg\lifeMin = 1.0 : *cfg\lifeMax = 2.2
      *cfg\colorStartR = 255 : *cfg\colorStartG = 250 : *cfg\colorStartB = 210
      *cfg\colorEndR = 255 : *cfg\colorEndG = 200 : *cfg\colorEndB = 120
      *cfg\gravity = 0.0
      *cfg\drag = 1.0
      *cfg\alphaStart = 0.8 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 1
      *cfg\glowSoftness = 1.0
      *cfg\shape = #SHAPE_LIGHTBURST
      *cfg\stretch = 7.0
      *cfg\pulseSpeed = 0.35
      *cfg\pulseAmount = 0.35
      *cfg\burstRays = 18
      *cfg\burstSpread = 360.0
      *cfg\burstWidth = 2.5
      *cfg\beamHalo = 0.9
      *cfg\beamEndGlow = 0.9

    Case "laser"
      *cfg\spawnRate = 35.0
      *cfg\spreadAngle = 10.0 : *cfg\baseAngle = 0.0
      *cfg\speedMin = 120.0 : *cfg\speedMax = 220.0
      *cfg\sizeStartMin = 2.0 : *cfg\sizeStartMax = 4.0
      *cfg\sizeEndMin = 2.0 : *cfg\sizeEndMax = 3.0
      *cfg\lifeMin = 0.25 : *cfg\lifeMax = 0.6
      *cfg\colorStartR = 255 : *cfg\colorStartG = 80 : *cfg\colorStartB = 120
      *cfg\colorEndR = 255 : *cfg\colorEndG = 30 : *cfg\colorEndB = 60
      *cfg\gravity = 0.0
      *cfg\drag = 1.0
      *cfg\alphaStart = 1.0 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 1
      *cfg\glowSoftness = 0.95
      *cfg\shape = #SHAPE_LASER
      *cfg\stretch = 10.0
      *cfg\pulseSpeed = 3.5
      *cfg\pulseAmount = 0.20
      *cfg\beamCore = 1.0
      *cfg\beamHalo = 0.5
      *cfg\beamEndGlow = 0.4

    Case "bubbles"
      ; Underwater bubbles rising from the bottom of the rect. Uses the
      ; new RING shape (hollow circle with subtle inner highlight) so
      ; bubbles look like actual bubbles, not glowing dots. Gentle
      ; horizontal wobble adds organic drift; gravity is slightly
      ; negative so bubbles accelerate as they rise (real fluid behaviour).
      *cfg\shape = #SHAPE_RING
      *cfg\spawnRate = 6.0
      *cfg\emitX = 0.5 : *cfg\emitY = 1.0
      *cfg\emitW = 1.0 : *cfg\emitH = 0.0
      *cfg\spreadAngle = 10.0 : *cfg\baseAngle = 270.0   ; up
      *cfg\speedMin = 25.0 : *cfg\speedMax = 60.0
      *cfg\sizeStartMin = 8.0 : *cfg\sizeStartMax = 18.0
      *cfg\sizeEndMin = 12.0 : *cfg\sizeEndMax = 26.0    ; bubbles grow as they rise
      *cfg\lifeMin = 3.0 : *cfg\lifeMax = 6.0
      *cfg\colorStartR = 200 : *cfg\colorStartG = 230 : *cfg\colorStartB = 255
      *cfg\colorEndR   = 220 : *cfg\colorEndG   = 240 : *cfg\colorEndB   = 255
      *cfg\gravity = -10.0
      *cfg\drag = 0.99
      *cfg\alphaStart = 0.85 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 0
      *cfg\glowSoftness = 1.0
      *cfg\fadeInFraction = 0.15
      *cfg\fadeOutFraction = 0.3

    Case "pulsewave"
      ; Single ring expanding from the center, used for click feedback
      ; or sonar-style alerts. Implemented as a Burst:1 of one large
      ; particle that grows from small to large while fading out.
      ; Mode = Burst:1 means one particle spawns, lives its full life,
      ; then the StepPreview auto-respawn fires it again -- giving the
      ; periodic-pulse appearance.
      *cfg\shape = #SHAPE_GLOW
      *cfg\spawnRate = 0.0
      *cfg\burstCount = 1
      *cfg\emitX = 0.5 : *cfg\emitY = 0.5
      *cfg\emitW = 0.0 : *cfg\emitH = 0.0
      *cfg\spreadAngle = 0.0 : *cfg\baseAngle = 0.0
      *cfg\speedMin = 0.0 : *cfg\speedMax = 0.0
      *cfg\sizeStartMin = 8.0 : *cfg\sizeStartMax = 8.0
      *cfg\sizeEndMin = 120.0 : *cfg\sizeEndMax = 120.0
      *cfg\lifeMin = 1.0 : *cfg\lifeMax = 1.0
      *cfg\colorStartR = 100 : *cfg\colorStartG = 200 : *cfg\colorStartB = 255
      *cfg\colorEndR   = 100 : *cfg\colorEndG   = 200 : *cfg\colorEndB   = 255
      *cfg\gravity = 0.0
      *cfg\drag = 1.0
      *cfg\alphaStart = 0.8 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 0
      *cfg\glowSoftness = 0.4   ; ring-like (lower softness = sharper edge)
      *cfg\fadeInFraction = 0.0
      *cfg\fadeOutFraction = 0.5

    Case "auroraborealis"
      ; Aurora Borealis curtain effect. Unlike the existing Aurora preset
      ; (which has flowing upward rays), this is designed as a HANGING
      ; CURTAIN: vertical streaks that stay roughly in place while
      ; shimmering through the green-purple aurora color spectrum.
      ;
      ; Key design decisions:
      ;   - Heavy drag (0.85): particles stop moving within ~0.3 sec,
      ;     leaving stationary vertical streaks like a curtain.
      ;   - Tall stretch (8.0): each particle is a tall vertical band.
      ;   - Multi-stop color: bright green core, magenta/purple tips,
      ;     matching real photographed auroras.
      ;   - Pulse + slow fade: each streak gently glows brighter and dimmer.
      ;   - Tip: combine with taperEdge=Bottom + taper=0.5 in options
      ;     for the classic "wider at top, narrower at horizon" curtain
      ;     drape, or taperEdge=Top + taper=0.7 for the opposite.
      *cfg\shape = #SHAPE_BEAM
      *cfg\spawnRate = 6.0
      *cfg\emitX = 0.5 : *cfg\emitY = 0.5
      *cfg\emitW = 1.0 : *cfg\emitH = 1.0
      *cfg\spreadAngle = 5.0 : *cfg\baseAngle = 270.0
      ; Tiny initial speed -- just enough to set BEAM rotation (vertical),
      ; then heavy drag holds particles in place as a curtain.
      *cfg\speedMin = 3.0 : *cfg\speedMax = 6.0
      *cfg\sizeStartMin = 8.0 : *cfg\sizeStartMax = 16.0
      *cfg\sizeEndMin = 8.0 : *cfg\sizeEndMax = 16.0
      *cfg\lifeMin = 4.0 : *cfg\lifeMax = 7.0
      ; Aurora color: green to magenta. 2-stop gradient (no midpoint)
      ; for predictable color overrides -- users only need to specify
      ; colorStart and colorEnd to get a clean gradient.
      *cfg\colorStartR = 100 : *cfg\colorStartG = 255 : *cfg\colorStartB = 180  ; vivid green
      *cfg\colorEndR   = 200 : *cfg\colorEndG   = 100 : *cfg\colorEndB   = 255  ; magenta
      *cfg\gravity = 0.0
      *cfg\drag = 0.85       ; heavy drag -- particles freeze in place
      *cfg\alphaStart = 0.4 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 1
      *cfg\glowSoftness = 1.5
      *cfg\fadeInFraction = 0.35
      *cfg\fadeOutFraction = 0.4
      *cfg\stretch = 8.0       ; very tall vertical streaks
      *cfg\feather = 0.20
      *cfg\beamCore = 1.0
      *cfg\beamHalo = 0.6
      *cfg\beamEndGlow = 0.3
      ; Pulsing shimmer: each streak's brightness gently waves
      *cfg\pulseSpeed = 0.6
      *cfg\pulseAmount = 0.35

    Case "aurora"
      ; Northern-lights effect: tall vertical bands that slowly drift
      ; horizontally and shift color across the green-purple spectrum.
      ; Uses BEAM shape (vertical bars) with very low alpha and additive
      ; blend so multiple bands blend into a luminous curtain.
      *cfg\shape = #SHAPE_BEAM
      *cfg\spawnRate = 4.0
      *cfg\emitX = 0.5 : *cfg\emitY = 0.5
      *cfg\emitW = 1.0 : *cfg\emitH = 0.0
      *cfg\spreadAngle = 5.0 : *cfg\baseAngle = 270.0    ; nearly vertical
      *cfg\speedMin = 8.0 : *cfg\speedMax = 20.0
      *cfg\sizeStartMin = 12.0 : *cfg\sizeStartMax = 24.0
      *cfg\sizeEndMin = 12.0 : *cfg\sizeEndMax = 24.0
      *cfg\lifeMin = 4.0 : *cfg\lifeMax = 8.0
      *cfg\colorStartR = 80  : *cfg\colorStartG = 255 : *cfg\colorStartB = 160  ; aurora green
      *cfg\colorEndR   = 180 : *cfg\colorEndG   = 100 : *cfg\colorEndB   = 255  ; aurora purple
      *cfg\gravity = 0.0
      *cfg\drag = 1.0
      *cfg\alphaStart = 0.25 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 1
      *cfg\glowSoftness = 1.0
      *cfg\fadeInFraction = 0.3
      *cfg\fadeOutFraction = 0.4
      *cfg\stretch = 8.0    ; tall vertical bands (height >> width)
      *cfg\beamCore = 1.0
      *cfg\beamHalo = 0.6
      *cfg\pulseSpeed = 0.4
      *cfg\pulseAmount = 0.4   ; slow shimmer

    Case "shimmer"
      ; Moonlight-on-water shimmer. BEAM particles produce streak highlights
      ; like wavelets catching reflected light. The fade-in fix in
      ; SpawnParticle (alpha=0 at spawn) means particles emerge smoothly
      ; instead of flashing -- this is what makes the shimmer work.
      *cfg\shape = #SHAPE_BEAM
      *cfg\spawnRate = 8.0
      *cfg\emitX = 0.5 : *cfg\emitY = 0.5
      *cfg\emitW = 1.0 : *cfg\emitH = 1.0
      *cfg\spreadAngle = 10.0 : *cfg\baseAngle = 270.0
      *cfg\speedMin = 3.0 : *cfg\speedMax = 6.0
      *cfg\sizeStartMin = 3.0 : *cfg\sizeStartMax = 7.0
      *cfg\sizeEndMin = 3.0 : *cfg\sizeEndMax = 7.0
      *cfg\lifeMin = 3.0 : *cfg\lifeMax = 5.0
      *cfg\colorStartR = 240 : *cfg\colorStartG = 245 : *cfg\colorStartB = 255
      *cfg\colorEndR   = 170 : *cfg\colorEndG   = 195 : *cfg\colorEndB   = 240
      *cfg\gravity = 0.0
      *cfg\drag = 0.85
      *cfg\alphaStart = 0.7 : *cfg\alphaEnd = 0.0
      *cfg\additiveBlend = 1
      *cfg\glowSoftness = 1.4
      *cfg\fadeInFraction = 0.3
      *cfg\fadeOutFraction = 0.4
      *cfg\stretch = 2.0
      *cfg\feather = 0.30
      *cfg\beamCore = 1.0
      *cfg\beamHalo = 0.5
      *cfg\beamEndGlow = 0.3
      *cfg\twinkleSpeed = 0.0
      *cfg\twinkleAmount = 0.0

    Default
      ApplyPreset("sparkle", *cfg)

  EndSelect
EndProcedure

; ============================================================
;  DIB management
; ============================================================

Procedure CreateARGBDIB(width.i, height.i, *outBits.Integer)
  Protected bmi.BITMAPINFO
  bmi\bmiHeader\biSize = SizeOf(BITMAPINFOHEADER)
  bmi\bmiHeader\biWidth = width
  bmi\bmiHeader\biHeight = -height
  bmi\bmiHeader\biPlanes = 1
  bmi\bmiHeader\biBitCount = 32
  bmi\bmiHeader\biCompression = #BI_RGB

  Protected *bits = 0
  Protected hDIB.i = CreateDIBSection_(0, @bmi, #DIB_RGB_COLORS, @*bits, 0, 0)
  If hDIB And *bits
    PokeI(*outBits, *bits)
    ProcedureReturn hDIB
  EndIf
  ProcedureReturn 0
EndProcedure

Procedure FreeDIB(*em.ParticleEmitter)
  If *em\hDIB
    DeleteObject_(*em\hDIB)
    *em\hDIB = 0
    *em\dibBits = 0
    *em\dibW = 0
    *em\dibH = 0
  EndIf
EndProcedure

Procedure.i EnsureDIB(*em.ParticleEmitter, w.i, h.i)
  If *em\hDIB And *em\dibW = w And *em\dibH = h
    ProcedureReturn 1
  EndIf
  FreeDIB(*em)
  Protected *bits.Integer = 0
  *em\hDIB = CreateARGBDIB(w, h, @*bits)
  If *em\hDIB
    *em\dibBits = *bits
    *em\dibW = w
    *em\dibH = h
    ProcedureReturn 1
  EndIf
  ProcedureReturn 0
EndProcedure

; ============================================================
;  Layered overlay window
; ============================================================

Procedure.i CreateOverlayWindow(*em.ParticleEmitter)
  If *em\hOverlay And IsWindow_(*em\hOverlay)
    ProcedureReturn *em\hOverlay
  EndIf

  Protected hParent.i = GetParent_(*em\hHost)
  If hParent = 0 : hParent = *em\hHost : EndIf

  Protected hOvr.i = CreateWindowEx_(#WS_EX_LAYERED | #WS_EX_TRANSPARENT | #WS_EX_TOOLWINDOW | #WS_EX_NOACTIVATE,
                                      "Static", "", #WS_POPUP,
                                      0, 0, 10, 10,
                                      hParent, 0, g_hInstance, 0)
  *em\hOverlay = hOvr
  ProcedureReturn hOvr
EndProcedure

Procedure PresentOverlay(*em.ParticleEmitter)
  ; Guard against the race where StopEmitter flips running to 0 after the
  ; timer callback's earlier check already passed -- without this, a slow
  ; render frame can paint one last frame of particles to an overlay we're
  ; about to destroy, which feels like "Stop didn't work" on heavy presets
  ; (e.g. dense Stars or high-Density continuous presets).
  If *em\running = 0 : ProcedureReturn : EndIf
  If *em\hOverlay = 0 Or *em\hDIB = 0 : ProcedureReturn : EndIf
  If IsWindow_(*em\hHost) = 0 : ProcedureReturn : EndIf

  Protected rc.RECT
  GetClientRect_(*em\hHost, @rc)
  Protected pt.POINT : pt\x = 0 : pt\y = 0
  ClientToScreen_(*em\hHost, @pt)

  Protected w.i = rc\right - rc\left
  Protected h.i = rc\bottom - rc\top
  If w <= 0 Or h <= 0 : ProcedureReturn : EndIf

  Protected hdcScreen.i = GetDC_(0)
  Protected hdcMem.i = CreateCompatibleDC_(hdcScreen)
  Protected hOld.i = SelectObject_(hdcMem, *em\hDIB)

  Protected sizeWnd.SIZE : sizeWnd\cx = w : sizeWnd\cy = h
  Protected ptSrc.POINT : ptSrc\x = 0 : ptSrc\y = 0
  Protected ptDst.POINT : ptDst\x = pt\x : ptDst\y = pt\y
  Protected bf.BLENDFUNCTION
  bf\BlendOp = 0
  bf\BlendFlags = 0
  bf\SourceConstantAlpha = 255
  bf\AlphaFormat = 1

  UpdateLayeredWindow_(*em\hOverlay, hdcScreen, @ptDst, @sizeWnd, hdcMem, @ptSrc, 0, @bf, 2)

  SelectObject_(hdcMem, hOld)
  DeleteDC_(hdcMem)
  ReleaseDC_(0, hdcScreen)

  If *em\topMost = 0
    SetWindowPos_(*em\hOverlay, #HWND_BOTTOM, pt\x, pt\y, w, h,
                  #SWP_NOACTIVATE | #SWP_SHOWWINDOW)
  Else
    SetWindowPos_(*em\hOverlay, #HWND_TOPMOST, pt\x, pt\y, w, h,
                  #SWP_NOACTIVATE | #SWP_SHOWWINDOW)
  EndIf
EndProcedure

Procedure RepositionOverlay(*em.ParticleEmitter)
  If *em\hOverlay = 0 Or IsWindow_(*em\hHost) = 0 : ProcedureReturn : EndIf
  Protected rc.RECT
  GetClientRect_(*em\hHost, @rc)
  Protected pt.POINT : pt\x = 0 : pt\y = 0
  ClientToScreen_(*em\hHost, @pt)
  Protected w.i = rc\right - rc\left
  Protected h.i = rc\bottom - rc\top
  If *em\topMost = 0
    SetWindowPos_(*em\hOverlay, #HWND_BOTTOM, pt\x, pt\y, w, h,
                  #SWP_NOACTIVATE)
  Else
    SetWindowPos_(*em\hOverlay, #HWND_TOPMOST, pt\x, pt\y, w, h,
                  #SWP_NOACTIVATE | #SWP_NOSIZE)
  EndIf
EndProcedure

; ============================================================
;  Particle spawning
; ============================================================

Procedure SpawnParticle(*em.ParticleEmitter)
  Protected *cfg.EmitterConfig = @*em\config
  Protected w.f = *em\dibW
  Protected h.f = *em\dibH
  If w <= 0 Or h <= 0 : ProcedureReturn : EndIf

  Protected i.i
  For i = 0 To #MAX_PARTICLES - 1
    If *em\particles[i]\alive = 0
      Protected *p.Particle = @*em\particles[i]
      *p\alive = 1

      Protected ex.f = *cfg\emitX * w + RandF(-0.5, 0.5) * *cfg\emitW * w
      Protected ey.f = *cfg\emitY * h + RandF(-0.5, 0.5) * *cfg\emitH * h

      ; Apply taper: constrain spawn position within a trapezoid.
      ; taper semantics (intuitive: 0 = off, 1 = max effect):
      ;   0.0 = no taper, rectangle (default)
      ;   0.5 = trapezoid, narrow edge is 50% of full width
      ;   1.0 = full taper, narrow edge is a single point (triangle)
      ; taperEdge selects which edge is the narrow one:
      ;   0 = top narrow, bottom wide (moonlight reflection)
      ;   1 = bottom narrow, top wide (fountain spray, inverted funnel)
      ;   2 = left narrow, right wide (horizontal fan, trumpet flare)
      ;   3 = right narrow, left wide (left-pointing fan)
      If *cfg\taper > 0.0 And *cfg\taper <= 1.0
        Protected nfrac.f       ; normalized position along the taper axis (0=narrow, 1=wide)
        Protected widthFrac.f
        Protected narrowW.f = 1.0 - *cfg\taper   ; width at the narrow edge
        Protected center.f
        Protected halfMax.f
        Select *cfg\taperEdge
          Case 1    ; bottom narrow, top wide -- invert y so ny=0 at bottom
            nfrac = 1.0 - (ey / h)
            If nfrac < 0.0 : nfrac = 0.0 : EndIf
            If nfrac > 1.0 : nfrac = 1.0 : EndIf
            widthFrac = narrowW + *cfg\taper * nfrac
            center = w * 0.5
            halfMax = w * widthFrac * 0.5
            ex = center + RandF(-halfMax, halfMax)
          Case 2    ; left narrow, right wide
            nfrac = ex / w
            If nfrac < 0.0 : nfrac = 0.0 : EndIf
            If nfrac > 1.0 : nfrac = 1.0 : EndIf
            widthFrac = narrowW + *cfg\taper * nfrac
            center = h * 0.5
            halfMax = h * widthFrac * 0.5
            ey = center + RandF(-halfMax, halfMax)
          Case 3    ; right narrow, left wide
            nfrac = 1.0 - (ex / w)
            If nfrac < 0.0 : nfrac = 0.0 : EndIf
            If nfrac > 1.0 : nfrac = 1.0 : EndIf
            widthFrac = narrowW + *cfg\taper * nfrac
            center = h * 0.5
            halfMax = h * widthFrac * 0.5
            ey = center + RandF(-halfMax, halfMax)
          Default   ; Case 0 -- top narrow, bottom wide
            nfrac = ey / h
            If nfrac < 0.0 : nfrac = 0.0 : EndIf
            If nfrac > 1.0 : nfrac = 1.0 : EndIf
            widthFrac = narrowW + *cfg\taper * nfrac
            center = w * 0.5
            halfMax = w * widthFrac * 0.5
            ex = center + RandF(-halfMax, halfMax)
        EndSelect
      EndIf

      *p\x = ex
      *p\y = ey

      Protected angle.f = *cfg\baseAngle + RandF(-*cfg\spreadAngle * 0.5, *cfg\spreadAngle * 0.5)
      Protected rad.f = angle * #PI / 180.0
      Protected spd.f = RandF(*cfg\speedMin, *cfg\speedMax)
      *p\vx = Cos(rad) * spd
      *p\vy = Sin(rad) * spd

      *p\life = RandF(*cfg\lifeMin, *cfg\lifeMax)
      *p\maxLife = *p\life

      *p\sizeStart = RandF(*cfg\sizeStartMin, *cfg\sizeStartMax)
      *p\sizeEnd = RandF(*cfg\sizeEndMin, *cfg\sizeEndMax)
      *p\size = *p\sizeStart

      ; Confetti rainbow only when no explicit colour override is set
      If *em\lcPresetName = "confetti" And *cfg\colorOverride = 0
        Protected hueChoice.i = Random(5)
        Select hueChoice
          Case 0 : *p\r = 255 : *p\g = 50  : *p\b = 50
          Case 1 : *p\r = 50  : *p\g = 200 : *p\b = 50
          Case 2 : *p\r = 50  : *p\g = 100 : *p\b = 255
          Case 3 : *p\r = 255 : *p\g = 220 : *p\b = 50
          Case 4 : *p\r = 255 : *p\g = 100 : *p\b = 200
          Case 5 : *p\r = 100 : *p\g = 220 : *p\b = 255
        EndSelect
        *p\r2 = *p\r : *p\g2 = *p\g : *p\b2 = *p\b
      Else
        *p\r = *cfg\colorStartR : *p\g = *cfg\colorStartG : *p\b = *cfg\colorStartB
        *p\r2 = *cfg\colorEndR  : *p\g2 = *cfg\colorEndG  : *p\b2 = *cfg\colorEndB
      EndIf

      *p\r1 = *p\r : *p\g1 = *p\g : *p\b1 = *p\b

      ; Start at alpha 0 (not alphaStart) so the first render frame
      ; respects the fade-in instead of flashing at full alpha for one
      ; frame before UpdateParticles recomputes. If fadeInFraction is 0,
      ; the next UpdateParticles call immediately bumps to alphaStart so
      ; nothing visible changes for presets that didn't want fade-in.
      *p\alpha = 0.0
      *p\gravity = *cfg\gravity
      ; Per-particle gravity multiplier: 1.0 +/- gravityVariance. With
      ; variance=0 this is always 1.0 (no variation). With variance=0.3
      ; particles get random multipliers in [0.7, 1.3].
      If *cfg\gravityVariance > 0.0
        *p\gravMod = 1.0 + RandF(-1.0, 1.0) * *cfg\gravityVariance
      Else
        *p\gravMod = 1.0
      EndIf
      *p\drag = *cfg\drag
      *p\fadeIn = *cfg\fadeInFraction
      *p\fadeOut = *cfg\fadeOutFraction
      *p\twinklePhase = RandF(0.0, 6.283)

      If *cfg\shape = #SHAPE_RAY Or *cfg\shape = #SHAPE_BEAM Or *cfg\shape = #SHAPE_LASER
        *p\rotation = VelocityAngleDeg(*p\vx, *p\vy)
        *p\rotSpeed = 0.0
      Else
        *p\rotation = RandF(0.0, 360.0)
        ; Tumble rate -- different defaults for different shapes:
        ;  RECT (confetti): aggressive spin, tumbling paper effect
        ;  SPRITE (clouds, smoke, leaves): gentle drift rotation; ±180/sec
        ;  is way too erratic for natural-looking sprite motion
        ;  Other shapes: no tumble (invisible on radial-symmetric particles)
        If *cfg\shape = #SHAPE_RECT
          *p\rotSpeed = RandF(-180.0, 180.0)
        ElseIf *cfg\shape = #SHAPE_SPRITE
          *p\rotSpeed = RandF(-30.0, 30.0)
        Else
          *p\rotSpeed = 0.0
        EndIf
      EndIf

      If *em\lcPresetName = "snow"
        *p\vx + RandF(-15.0, 15.0)
      EndIf

      *em\activeCount + 1
      ProcedureReturn
    EndIf
  Next
EndProcedure

; ============================================================
;  Particle update (physics + aging)
; ============================================================

Procedure UpdateParticles(*em.ParticleEmitter, dt.f)
  Protected *cfg.EmitterConfig = @*em\config
  Protected i.i
  Protected count.i = 0

  ; --- Resolution-independent motion -------------------------------------
  ; Particle velocities (speed, wind, noise, sways) are authored in pixels/sec.
  ; Spawn position and particle SIZE scale with the box (dibW/dibH), but raw
  ; velocity does NOT -- so at a smaller box, particles travel a larger FRACTION
  ; of the box over their life, shifting/spreading the visible column. That is a
  ; (1 - scale)-shaped drift. Fix: scale all motion by dibH relative to the box
  ; height captured the first time this emitter spawned (refH). At the reference
  ; size velScale = 1.0 (identical to original behavior); at half height, motion
  ; runs at half speed, covering the same fraction of the box -> identical look
  ; and a column that stays put at every resolution.
  If *em\refH <= 0
    If *em\dibH > 0
      *em\refH = *em\dibH       ; capture reference on first valid frame
    Else
      *em\refH = 1              ; guard; avoids divide-by-zero before DIB exists
    EndIf
  EndIf
  Protected velScale.f = 1.0
  If *em\refH > 0
    velScale = *em\dibH / *em\refH
  EndIf
  If velScale <= 0.0 : velScale = 1.0 : EndIf

  For i = 0 To #MAX_PARTICLES - 1
    If *em\particles[i]\alive
      Protected *p.Particle = @*em\particles[i]

      *p\life - dt
      If *p\life <= 0.0
        *p\alive = 0
        Continue
      EndIf

      Protected t.f = 1.0 - (*p\life / *p\maxLife)

      ; Gravity with per-particle multiplier (gravMod), plus optional wind
      *p\vy + *p\gravity * *p\gravMod * dt
      If *cfg\windX <> 0.0 : *p\vx + *cfg\windX * dt : EndIf
      If *cfg\windY <> 0.0 : *p\vy + *cfg\windY * dt : EndIf

      ; Perlin noise turbulence (the Fire-fix sprint feature). Samples noise
      ; at the particle's position and uses the result to perturb velocity.
      ; Two samples (X and Y offsets in the noise field by +500 to decorrelate)
      ; give independent X/Y forces. Output mapped from [0,1] -> [-1,1].
      If *cfg\noisePower > 0.0
        Protected nScale.f = *cfg\noiseScale
        If nScale < 1.0 : nScale = 1.0 : EndIf
        Protected nTime.f = *em\timeAcc * *cfg\noiseSpeed
        Protected nx.f = *p\x / nScale + nTime
        Protected ny.f = *p\y / nScale + nTime
        *p\vx + (NoiseSample(nx,         ny)         * 2.0 - 1.0) * *cfg\noisePower * dt
        *p\vy + (NoiseSample(nx + 500.0, ny + 500.0) * 2.0 - 1.0) * *cfg\noisePower * dt
      EndIf

      If *p\drag < 1.0
        Protected dragFactor.f = Pow(*p\drag, dt * 60.0)
        *p\vx * dragFactor
        *p\vy * dragFactor
      EndIf

      If *em\lcPresetName = "snow"
        *p\vx + Sin(t * 12.0 + i * 0.3) * 0.5
      EndIf

      ; Bubbles wobble: similar to snow but slower (real bubbles drift
      ; lazily) and slightly higher amplitude. Per-particle phase (i*0.5)
      ; gives each bubble its own wandering pattern.
      If *em\lcPresetName = "bubbles"
        *p\vx + Sin(t * 6.0 + i * 0.5) * 0.8
      EndIf

      ; Aurora horizontal sway: very slow lateral drift to give the bands
      ; a curtain-like undulation. Each band (particle) has its own phase
      ; via i*0.7, so the curtain doesn't move in lockstep.
      If *em\lcPresetName = "aurora"
        *p\vx + Sin(*em\timeAcc * 0.5 + i * 0.7) * 1.5
      EndIf

      *p\x + *p\vx * velScale * dt
      *p\y + *p\vy * velScale * dt

      If *cfg\shape = #SHAPE_RAY Or *cfg\shape = #SHAPE_BEAM Or *cfg\shape = #SHAPE_LASER
        ; Only update rotation when velocity is meaningfully nonzero.
        ; With heavy drag (e.g. Shimmer preset), velocity decays to
        ; sub-px/sec values where direction becomes unreliable -- a tiny
        ; remaining vy with vx near 0 would otherwise snap the rotation
        ; to vertical even for an originally-horizontal particle.
        ; Threshold of 1.0 px/sec preserves the spawn-time rotation
        ; once drag has effectively halted motion.
        Protected vMag.f = Abs(*p\vx) + Abs(*p\vy)
        If vMag > 1.0
          *p\rotation = VelocityAngleDeg(*p\vx, *p\vy)
        EndIf
      ElseIf *cfg\shape = #SHAPE_RECT Or *cfg\shape = #SHAPE_SPRITE
        ; Tumble (degrees per second). rotSpeed is randomised per-particle
        ; in SpawnParticle so confetti pieces tumble at varied rates.
        *p\rotation + *p\rotSpeed * dt
      EndIf

      *p\size = LerpF(*p\sizeStart, *p\sizeEnd, t)

      ; 3-stop color gradient (Fire-fix sprint feature).
      ; When hasColorMid = 0: legacy 2-stop lerp Start->End.
      ; When hasColorMid = 1: split at colorMidPos. From 0..midPos we lerp
      ; r1 -> colorMid; from midPos..1 we lerp colorMid -> r2.
      ; This is what gives Fire its hot-warm-cool color arc that simple
      ; 2-stop lerps can't reproduce (they pass through muddy mid-tones).
      If *cfg\hasColorMid
        Protected midPos.f = *cfg\colorMidPos
        If midPos < 0.001 : midPos = 0.001 : EndIf
        If midPos > 0.999 : midPos = 0.999 : EndIf
        If t < midPos
          Protected tLow.f = t / midPos
          *p\r = LerpI(*p\r1, *cfg\colorMidR, tLow)
          *p\g = LerpI(*p\g1, *cfg\colorMidG, tLow)
          *p\b = LerpI(*p\b1, *cfg\colorMidB, tLow)
        Else
          Protected tHigh.f = (t - midPos) / (1.0 - midPos)
          *p\r = LerpI(*cfg\colorMidR, *p\r2, tHigh)
          *p\g = LerpI(*cfg\colorMidG, *p\g2, tHigh)
          *p\b = LerpI(*cfg\colorMidB, *p\b2, tHigh)
        EndIf
      Else
        *p\r = LerpI(*p\r1, *p\r2, t)
        *p\g = LerpI(*p\g1, *p\g2, t)
        *p\b = LerpI(*p\b1, *p\b2, t)
      EndIf

      Protected baseAlpha.f = LerpF(*cfg\alphaStart, *cfg\alphaEnd, t)
      If *p\fadeIn > 0.0 And t < *p\fadeIn
        baseAlpha * (t / *p\fadeIn)
      EndIf
      ; Fade-out at end of life: smooth taper to 0 over the last fadeOut
      ; fraction of life, so particles don't pop out abruptly when alive=0.
      ; Especially important for stacked sprites where a single death is
      ; otherwise visible as a sudden change in the topmost cluster pixel.
      If *p\fadeOut > 0.0 And t > 1.0 - *p\fadeOut
        baseAlpha * ((1.0 - t) / *p\fadeOut)
      EndIf
      *p\alpha = ClampF(baseAlpha, 0.0, 1.0)

      count + 1
    EndIf
  Next

  *em\activeCount = count

  ; Continuous spawn
  If *cfg\burstCount = 0 And *em\burstDone = 0
    *em\spawnAccum + *cfg\spawnRate * dt
    While *em\spawnAccum >= 1.0
      SpawnParticle(*em)
      *em\spawnAccum - 1.0
    Wend
  EndIf
EndProcedure

; ============================================================
;  Particle rendering (GDI+ soft glow)
; ============================================================

Procedure RenderParticles(*em.ParticleEmitter)
  If *em\hDIB = 0 Or *em\dibBits = 0 Or *em\running = 0 : ProcedureReturn : EndIf
  If *em\dibW <= 0 Or *em\dibH <= 0 : ProcedureReturn : EndIf
  If EnsureGDIPlus() = 0 : ProcedureReturn : EndIf

  Protected w.i = *em\dibW
  Protected h.i = *em\dibH
  Protected totalBytes.i = w * h * 4

  FillMemory(*em\dibBits, totalBytes, 0)

  Protected hdcMem.i = CreateCompatibleDC_(0)
  Protected hOld.i = SelectObject_(hdcMem, *em\hDIB)

  Protected graphics.i = 0
  GdipCreateFromHDC(hdcMem, @graphics)
  If graphics = 0
    SelectObject_(hdcMem, hOld)
    DeleteDC_(hdcMem)
    ProcedureReturn
  EndIf

  GdipSetSmoothingMode(graphics, #SmoothingModeAntiAlias)
  GdipSetCompositingQuality(graphics, #CompositingQualityHighSpeed)
  GdipSetPixelOffsetMode(graphics, #PixelOffsetModeHighSpeed)

  Protected *cfg.EmitterConfig = @*em\config
  Protected useGlow.i = Bool(*cfg\glowSoftness > 0.3)

  Protected coreA.i, haloA.i, endA.i

  ; ---- Sprite-frame setup (only when shape=SPRITE and a sprite is loaded) ----
  ; Hoisting these out of the per-particle loop is essential -- creating
  ; an ImageAttributes per particle (50+ per frame, 30+ fps) was
  ; thrashing GDI+ resources, causing both visible sluggishness and
  ; sporadic crashes on heavy frames.
  Protected hImageAttr.i = 0
  Protected cm.ColorMatrix5x5
  If *cfg\shape = #SHAPE_SPRITE And *em\hSpriteImage <> 0
    ; NearestNeighbor at render time. Sprites have already been pre-resized
    ; to 512x512 max at load (with HQ bicubic), so the runtime resampling
    ; is small and quality loss from NN is imperceptible at typical particle
    ; sizes. NN is 10x+ faster than Bilinear, which is what makes per-frame
    ; sprite rendering fast enough for high spawn rates.
    GdipSetInterpolationMode(graphics, #InterpolationModeNearestNeighbor)
    GdipCreateImageAttributes(@hImageAttr)
    cm\m[24] = 1.0
  EndIf

  ; Hoisted solid brush, reused across all particles in this frame for
  ; CIRCLE (solid fallback) and RECT (confetti) shapes. Without this, each
  ; particle was creating + destroying its own brush -- 50+ allocations per
  ; frame at 30 fps. GdipSetSolidFillColor lets us recolor the same brush
  ; instance per particle with no allocation cost.
  Protected hSolidBrush.i = 0
  GdipCreateSolidFill($FFFFFFFF, @hSolidBrush)

  ; Early-exit optimization: track how many alive particles we've seen
  ; and stop scanning once we've rendered all of them. Without this, every
  ; render frame walks all 500 slots even if only 30 are alive.
  Protected aliveSeen.i = 0
  Protected aliveTarget.i = *em\activeCount

  Protected i.i
  For i = 0 To #MAX_PARTICLES - 1
    If aliveSeen >= aliveTarget : Break : EndIf
    If *em\particles[i]\alive = 0 : Continue : EndIf
    aliveSeen + 1
    Protected *p.Particle = @*em\particles[i]

    Protected px.f = *p\x
    Protected py.f = *p\y
    Protected sz.f = *p\size * 2.0
    Protected szW.f = sz * *cfg\stretch
    Protected szH.f = sz

    Protected halfW.f = szW * 0.5
    Protected halfH.f = szH * 0.5
    ; Sprites render at 4x the primitive size, so use a wider cull margin
    ; for them. Otherwise sprite particles get culled before they should.
    Protected cullHalfW.f = halfW
    Protected cullHalfH.f = halfH
    If *cfg\shape = #SHAPE_SPRITE
      cullHalfW = halfW * 4.0
      cullHalfH = halfH * 4.0
    EndIf
    If px + cullHalfW < 0 Or px - cullHalfW > w Or py + cullHalfH < 0 Or py - cullHalfH > h
      Continue
    EndIf

    Protected a.i = ClampI(Round(*p\alpha * 255.0, #PB_Round_Nearest), 0, 255)
    If a = 0 : Continue : EndIf

    If *cfg\pulseSpeed > 0.0 And *cfg\pulseAmount > 0.0
      Protected pulse.f = 1.0 + Sin((*em\timeAcc * *cfg\pulseSpeed * 6.2831853) + *p\twinklePhase) * *cfg\pulseAmount
      a = ClampI(Round(a * pulse, #PB_Round_Nearest), 0, 255)
      If a = 0 : Continue : EndIf
    EndIf

    If *cfg\feather > 0.0
      Protected fZoneH.f, fZoneV.f
      If *cfg\feather > 1.0
        fZoneH = *cfg\feather
        fZoneV = *cfg\feather
      Else
        fZoneH = *cfg\feather * w
        fZoneV = *cfg\feather * h
      EndIf
      Protected pRad.f = *p\size
      ; Stretched shapes (Ray, Beam, Aurora, Fire) can have very long
      ; horizontal extent. Without a cap, pRad * stretch can grow larger
      ; than the rect itself, making the feather zone bigger than the
      ; whole rect -- every particle ends up in fade zone with alpha ~0.
      ; Cap the horizontal-radius contribution at 2x the base radius.
      Protected pStretchClamped.f = *cfg\stretch
      If pStretchClamped > 2.0 : pStretchClamped = 2.0 : EndIf
      Protected pRadH.f = pRad * pStretchClamped
      Protected edgeFade.f = 1.0

      Protected dLeft.f   = px
      Protected dRight.f  = w - px
      Protected dTop.f    = py
      Protected dBottom.f = h - py

      Protected fzH.f = fZoneH + pRadH
      Protected fzV.f = fZoneV + pRad

      If dLeft < fzH And fzH > 0
        Protected fl.f = dLeft / fzH
        If fl < edgeFade : edgeFade = fl : EndIf
      EndIf
      If dRight < fzH And fzH > 0
        Protected fr.f = dRight / fzH
        If fr < edgeFade : edgeFade = fr : EndIf
      EndIf
      If dTop < fzV And fzV > 0
        Protected ft.f = dTop / fzV
        If ft < edgeFade : edgeFade = ft : EndIf
      EndIf
      If dBottom < fzV And fzV > 0
        Protected fb.f = dBottom / fzV
        If fb < edgeFade : edgeFade = fb : EndIf
      EndIf

      If edgeFade < 0.0 : edgeFade = 0.0 : EndIf
      If edgeFade > 1.0 : edgeFade = 1.0 : EndIf
      edgeFade = edgeFade * edgeFade

      a = ClampI(Round(a * edgeFade, #PB_Round_Nearest), 0, 255)
      If a = 0 : Continue : EndIf
    EndIf

    ; ---- Spawn-boundary fade (softens the flat line at the wide taper edge) --
    ; Feather fades a particle's ALPHA as its centre nears a RECT edge. It does
    ; not soften the DENSITY discontinuity at the edge of the SPAWN region: with
    ; a vertical taper, the wide end of the trapezoid is a dense, full-width row
    ; of freshly-spawned particles with a hard cutoff (nothing fresh beyond it),
    ; which reads as a crisp flat line across that end. We fade alpha to zero as
    ; a particle approaches that wide spawn edge, turning the hard band edge into
    ; a gradient. The fade zone reuses 'feather' (intuitive: feather softens all
    ; edges, including the spawn line), so no new parameter is needed; feather=0
    ; disables it. Only the VERTICAL taper edges (Top/Bottom) get this -- those
    ; are the flame-style cases where the artifact appears. Particles that have
    ; already travelled PAST the wide edge are left to the life-fade + feather
    ; above (factor 1 here) so we never double-darken them.
    If *cfg\feather > 0.0 And (*cfg\taper > 0.0 And *cfg\taper <= 1.0) And (*cfg\taperEdge = 0 Or *cfg\taperEdge = 1)
      ; Spawn band edges in render pixels (emitY/emitH are rect fractions).
      Protected sbTopFrac.f = *cfg\emitY - 0.5 * *cfg\emitH
      Protected sbBotFrac.f = *cfg\emitY + 0.5 * *cfg\emitH
      If sbTopFrac < 0.0 : sbTopFrac = 0.0 : EndIf
      If sbBotFrac > 1.0 : sbBotFrac = 1.0 : EndIf
      Protected sbTop.f = sbTopFrac * h
      Protected sbBot.f = sbBotFrac * h
      Protected sbBandH.f = sbBot - sbTop
      If sbBandH > 0.0
        ; Fade zone, capped so it can't swallow the whole band.
        Protected sbZone.f = fZoneV    ; = feather*h (or feather px if feather>1), from the feather block
        If sbZone > sbBandH * 0.9 : sbZone = sbBandH * 0.9 : EndIf
        If sbZone > 0.0
          Protected sbFade.f = 1.0
          If *cfg\taperEdge = 1
            ; Bottom-narrow -> TOP is the wide, dense edge. Fade particles in the
            ; top 'sbZone' of the band (py just below sbTop), deepening to full.
            If py >= sbTop And (py - sbTop) < sbZone
              sbFade = (py - sbTop) / sbZone
            EndIf
          Else
            ; taperEdge = 0 (Top-narrow) -> BOTTOM is the wide, dense edge.
            If py <= sbBot And (sbBot - py) < sbZone
              sbFade = (sbBot - py) / sbZone
            EndIf
          EndIf
          If sbFade < 1.0
            If sbFade < 0.0 : sbFade = 0.0 : EndIf
            sbFade = sbFade * sbFade   ; squared, matches feather's smoothing
            a = ClampI(Round(a * sbFade, #PB_Round_Nearest), 0, 255)
            If a = 0 : Continue : EndIf
          EndIf
        EndIf
      EndIf
    EndIf

    Protected cr.i = ClampI(*p\r, 0, 255)
    Protected cg.i = ClampI(*p\g, 0, 255)
    Protected cb.i = ClampI(*p\b, 0, 255)

    If *cfg\twinkleSpeed > 0.0 And *cfg\twinkleAmount > 0.0
      Protected twinkleTime.f = GetTickCount_() / 1000.0
      Protected twinkle.f = Sin(twinkleTime * *cfg\twinkleSpeed * 6.283 + *p\twinklePhase)
      Protected twinkleMul.f = 1.0 - *cfg\twinkleAmount * 0.5 * (1.0 - twinkle)
      a = ClampI(Round(a * twinkleMul, #PB_Round_Nearest), 0, 255)
      If a = 0 : Continue : EndIf
    EndIf

    Protected argbColor.l = (a << 24) | (cr << 16) | (cg << 8) | cb
    Protected transColor.l = (0 << 24) | (cr << 16) | (cg << 8) | cb

    Protected rectX.f = px - halfW
    Protected rectY.f = py - halfH

    Select *cfg\shape

      Case #SHAPE_RAY
        coreA = ClampI(Round(a * *cfg\beamCore, #PB_Round_Nearest), 0, 255)
        haloA = ClampI(Round(a * *cfg\beamHalo, #PB_Round_Nearest), 0, 255)
        endA = ClampI(Round(a * *cfg\beamEndGlow, #PB_Round_Nearest), 0, 255)
        Protected coreColor.l = (coreA << 24) | (cr << 16) | (cg << 8) | cb
        Protected haloColor.l = (haloA << 24) | (cr << 16) | (cg << 8) | cb
        Protected endColor.l  = (endA  << 24) | (cr << 16) | (cg << 8) | cb

        GdipTranslateWorldTransform(graphics, px, py, 0)
        GdipRotateWorldTransform(graphics, *p\rotation, 0)

        Protected rayLen.f = szH * *cfg\stretch
        ; Width factor was 0.12 (line-like, 25:1 aspect at stretch 3.0).
        ; HTML reference uses half-width = sz * 0.35 which translates to
        ; szH * 0.30 in PB's coord system. Aspect at stretch 3.0 is now
        ; ~4.3:1 -- tongue-shaped instead of line-shaped. LightRays still
        ; looks ray-like since stretch 6.0 gives ~8.6:1 aspect.
        Protected rayThick.f = szH * 0.30
        If rayThick < 2.0 : rayThick = 2.0 : EndIf
        Protected haloThick.f = rayThick * 2.3
        Protected rayX.f = -rayLen * 0.5

        Protected rayPath.i = 0
        GdipCreatePath(0, @rayPath)
        If rayPath
          GdipAddPathEllipse(rayPath, rayX, -haloThick * 0.5, rayLen, haloThick)
          Protected rayBrush.i = 0
          GdipCreatePathGradientFromPath(rayPath, @rayBrush)
          If rayBrush
            GdipSetPathGradientCenterColor(rayBrush, haloColor)
            Protected surr.l = transColor
            Protected sc.l = 1
            GdipSetPathGradientSurroundColorsWithCount(rayBrush, @surr, @sc)
            GdipFillEllipse(graphics, rayBrush, rayX, -haloThick * 0.5, rayLen, haloThick)
            GdipDeleteBrush(rayBrush)
          EndIf
          GdipDeletePath(rayPath)
        EndIf

        rayPath = 0
        GdipCreatePath(0, @rayPath)
        If rayPath
          GdipAddPathEllipse(rayPath, rayX, -rayThick * 0.5, rayLen, rayThick)
          rayBrush = 0
          GdipCreatePathGradientFromPath(rayPath, @rayBrush)
          If rayBrush
            GdipSetPathGradientCenterColor(rayBrush, coreColor)
            surr = transColor : sc = 1
            GdipSetPathGradientSurroundColorsWithCount(rayBrush, @surr, @sc)
            GdipFillEllipse(graphics, rayBrush, rayX, -rayThick * 0.5, rayLen, rayThick)
            GdipDeleteBrush(rayBrush)
          EndIf
          GdipDeletePath(rayPath)
        EndIf

        If endA > 0
          Protected endSz.f = haloThick * 1.4
          Protected ex1.f = -rayLen * 0.5 - endSz * 0.2
          Protected ex2.f =  rayLen * 0.5 - endSz * 0.8
          Protected ey.f = -endSz * 0.5
          rayPath = 0
          GdipCreatePath(0, @rayPath)
          If rayPath
            GdipAddPathEllipse(rayPath, ex1, ey, endSz, endSz)
            rayBrush = 0
            GdipCreatePathGradientFromPath(rayPath, @rayBrush)
            If rayBrush
              GdipSetPathGradientCenterColor(rayBrush, endColor)
              surr = transColor : sc = 1
              GdipSetPathGradientSurroundColorsWithCount(rayBrush, @surr, @sc)
              GdipFillEllipse(graphics, rayBrush, ex1, ey, endSz, endSz)
              GdipDeleteBrush(rayBrush)
            EndIf
            GdipDeletePath(rayPath)
          EndIf
          rayPath = 0
          GdipCreatePath(0, @rayPath)
          If rayPath
            GdipAddPathEllipse(rayPath, ex2, ey, endSz, endSz)
            rayBrush = 0
            GdipCreatePathGradientFromPath(rayPath, @rayBrush)
            If rayBrush
              GdipSetPathGradientCenterColor(rayBrush, endColor)
              surr = transColor : sc = 1
              GdipSetPathGradientSurroundColorsWithCount(rayBrush, @surr, @sc)
              GdipFillEllipse(graphics, rayBrush, ex2, ey, endSz, endSz)
              GdipDeleteBrush(rayBrush)
            EndIf
            GdipDeletePath(rayPath)
          EndIf
        EndIf

        GdipResetWorldTransform(graphics)


      Case #SHAPE_BEAM
        coreA = ClampI(Round(a * *cfg\beamCore, #PB_Round_Nearest), 0, 255)
        haloA = ClampI(Round(a * *cfg\beamHalo, #PB_Round_Nearest), 0, 255)
        endA = ClampI(Round(a * *cfg\beamEndGlow, #PB_Round_Nearest), 0, 255)
        coreColor = (coreA << 24) | (cr << 16) | (cg << 8) | cb
        haloColor = (haloA << 24) | (cr << 16) | (cg << 8) | cb
        endColor = (endA  << 24) | (cr << 16) | (cg << 8) | cb

        GdipTranslateWorldTransform(graphics, px, py, 0)
        GdipRotateWorldTransform(graphics, *p\rotation, 0)

        Protected beamLen.f = szH * *cfg\stretch
        Protected beamThick.f = szH * 0.45
        If beamThick < 3.0 : beamThick = 3.0 : EndIf
        Protected haloThick2.f = beamThick * 2.0
        Protected bx.f = -beamLen * 0.5

        Protected beamPath.i = 0
        GdipCreatePath(0, @beamPath)
        If beamPath
          GdipAddPathEllipse(beamPath, bx, -haloThick2 * 0.5, beamLen, haloThick2)
          Protected beamBrush.i = 0
          GdipCreatePathGradientFromPath(beamPath, @beamBrush)
          If beamBrush
            GdipSetPathGradientCenterColor(beamBrush, haloColor)
            Protected surr2.l = transColor
            Protected sc2.l = 1
            GdipSetPathGradientSurroundColorsWithCount(beamBrush, @surr2, @sc2)
            GdipFillEllipse(graphics, beamBrush, bx, -haloThick2 * 0.5, beamLen, haloThick2)
            GdipDeleteBrush(beamBrush)
          EndIf
          GdipDeletePath(beamPath)
        EndIf

        beamPath = 0
        GdipCreatePath(0, @beamPath)
        If beamPath
          GdipAddPathEllipse(beamPath, bx, -beamThick * 0.5, beamLen, beamThick)
          beamBrush = 0
          GdipCreatePathGradientFromPath(beamPath, @beamBrush)
          If beamBrush
            GdipSetPathGradientCenterColor(beamBrush, coreColor)
            surr2 = transColor : sc2 = 1
            GdipSetPathGradientSurroundColorsWithCount(beamBrush, @surr2, @sc2)
            GdipFillEllipse(graphics, beamBrush, bx, -beamThick * 0.5, beamLen, beamThick)
            GdipDeleteBrush(beamBrush)
          EndIf
          GdipDeletePath(beamPath)
        EndIf

        If endA > 0
          Protected endSz2.f = haloThick2 * 1.1
          Protected ex.f = beamLen * 0.5 - endSz2 * 0.6
          Protected ey2.f = -endSz2 * 0.5
          beamPath = 0
          GdipCreatePath(0, @beamPath)
          If beamPath
            GdipAddPathEllipse(beamPath, ex, ey2, endSz2, endSz2)
            beamBrush = 0
            GdipCreatePathGradientFromPath(beamPath, @beamBrush)
            If beamBrush
              GdipSetPathGradientCenterColor(beamBrush, endColor)
              surr2 = transColor : sc2 = 1
              GdipSetPathGradientSurroundColorsWithCount(beamBrush, @surr2, @sc2)
              GdipFillEllipse(graphics, beamBrush, ex, ey2, endSz2, endSz2)
              GdipDeleteBrush(beamBrush)
            EndIf
            GdipDeletePath(beamPath)
          EndIf
        EndIf

        GdipResetWorldTransform(graphics)


      Case #SHAPE_LASER
        coreA = ClampI(Round(a * *cfg\beamCore, #PB_Round_Nearest), 0, 255)
        haloA = ClampI(Round(a * *cfg\beamHalo, #PB_Round_Nearest), 0, 255)
        coreColor = (coreA << 24) | (cr << 16) | (cg << 8) | cb
        haloColor = (haloA << 24) | (cr << 16) | (cg << 8) | cb

        GdipTranslateWorldTransform(graphics, px, py, 0)
        GdipRotateWorldTransform(graphics, *p\rotation, 0)

        Protected lenL.f = szH * *cfg\stretch
        Protected thickCore.f = szH * 0.10
        If thickCore < 1.5 : thickCore = 1.5 : EndIf
        Protected thickHalo.f = thickCore * 5.0
        Protected xL.f = -lenL * 0.5

        Protected lPath.i = 0
        GdipCreatePath(0, @lPath)
        If lPath
          GdipAddPathEllipse(lPath, xL, -thickHalo * 0.5, lenL, thickHalo)
          Protected lBrush.i = 0
          GdipCreatePathGradientFromPath(lPath, @lBrush)
          If lBrush
            GdipSetPathGradientCenterColor(lBrush, haloColor)
            Protected s3.l = transColor
            Protected c3.l = 1
            GdipSetPathGradientSurroundColorsWithCount(lBrush, @s3, @c3)
            GdipFillEllipse(graphics, lBrush, xL, -thickHalo * 0.5, lenL, thickHalo)
            GdipDeleteBrush(lBrush)
          EndIf
          GdipDeletePath(lPath)
        EndIf

        Protected pen.i = 0
        GdipCreatePen1(coreColor, thickCore, 2, @pen)
        If pen
          GdipDrawLine(graphics, pen, xL, 0.0, xL + lenL, 0.0)
          GdipDeletePen(pen)
        EndIf

        GdipResetWorldTransform(graphics)


      Case #SHAPE_LIGHTBURST
        Protected rays.i = *cfg\burstRays
        If rays < 3 : rays = 3 : EndIf
        Protected spread.f = *cfg\burstSpread
        If spread <= 0.0 : spread = 360.0 : EndIf

        coreA = ClampI(Round(a * *cfg\beamCore, #PB_Round_Nearest), 0, 255)
        haloA = ClampI(Round(a * *cfg\beamHalo, #PB_Round_Nearest), 0, 255)
        endA = ClampI(Round(a * *cfg\beamEndGlow, #PB_Round_Nearest), 0, 255)
        coreColor = (coreA << 24) | (cr << 16) | (cg << 8) | cb
        haloColor = (haloA << 24) | (cr << 16) | (cg << 8) | cb
        endColor = (endA  << 24) | (cr << 16) | (cg << 8) | cb

        Protected baseWidth.f = *cfg\burstWidth
        If baseWidth < 0.5 : baseWidth = 0.5 : EndIf
        Protected lenB.f = szH * *cfg\stretch * 1.2

        Protected k.i
        For k = 0 To rays - 1
          Protected ang.f = *p\rotation + (-spread * 0.5) + (spread * k / rays)

          GdipResetWorldTransform(graphics)
          GdipTranslateWorldTransform(graphics, px, py, 0)
          GdipRotateWorldTransform(graphics, ang, 0)

          Protected penH.i = 0
          GdipCreatePen1(haloColor, baseWidth * 2.8, 2, @penH)
          If penH
            GdipDrawLine(graphics, penH, 0.0, 0.0, lenB, 0.0)
            GdipDeletePen(penH)
          EndIf

          Protected penC.i = 0
          GdipCreatePen1(coreColor, baseWidth, 2, @penC)
          If penC
            GdipDrawLine(graphics, penC, 0.0, 0.0, lenB, 0.0)
            GdipDeletePen(penC)
          EndIf
        Next

        If endA > 0
          GdipResetWorldTransform(graphics)
          GdipTranslateWorldTransform(graphics, px, py, 0)

          Protected csz.f = szH * 1.2
          Protected cx.f = -csz * 0.5
          Protected cy.f = -csz * 0.5
          Protected cPath.i = 0
          GdipCreatePath(0, @cPath)
          If cPath
            GdipAddPathEllipse(cPath, cx, cy, csz, csz)
            Protected cBrush.i = 0
            GdipCreatePathGradientFromPath(cPath, @cBrush)
            If cBrush
              GdipSetPathGradientCenterColor(cBrush, endColor)
              Protected s4.l = transColor
              Protected c4.l = 1
              GdipSetPathGradientSurroundColorsWithCount(cBrush, @s4, @c4)
              GdipFillEllipse(graphics, cBrush, cx, cy, csz, csz)
              GdipDeleteBrush(cBrush)
            EndIf
            GdipDeletePath(cPath)
          EndIf
        EndIf

        GdipResetWorldTransform(graphics)


      Case #SHAPE_GLOW
        Protected glowPath.i = 0
        Protected glowSz.f = szH * 1.5
        Protected glowSzW.f = glowSz * *cfg\stretch
        Protected glowX.f = px - glowSzW * 0.5
        Protected glowY.f = py - glowSz * 0.5
        GdipCreatePath(0, @glowPath)
        If glowPath
          GdipAddPathEllipse(glowPath, glowX, glowY, glowSzW, glowSz)
          Protected glowBrush.i = 0
          GdipCreatePathGradientFromPath(glowPath, @glowBrush)
          If glowBrush
            GdipSetPathGradientCenterColor(glowBrush, argbColor)
            Protected glowSurround.l = transColor
            Protected glowSurrCount.l = 1
            GdipSetPathGradientSurroundColorsWithCount(glowBrush, @glowSurround, @glowSurrCount)
            GdipFillEllipse(graphics, glowBrush, glowX, glowY, glowSzW, glowSz)
            GdipDeleteBrush(glowBrush)
          EndIf
          GdipDeletePath(glowPath)
        EndIf

      Case #SHAPE_STAR
        Protected starDotSz.f = szH * 0.6
        Protected starDotX.f = px - starDotSz * 0.5
        Protected starDotY.f = py - starDotSz * 0.5
        Protected starDotPath.i = 0
        GdipCreatePath(0, @starDotPath)
        If starDotPath
          GdipAddPathEllipse(starDotPath, starDotX, starDotY, starDotSz, starDotSz)
          Protected starDotBrush.i = 0
          GdipCreatePathGradientFromPath(starDotPath, @starDotBrush)
          If starDotBrush
            GdipSetPathGradientCenterColor(starDotBrush, argbColor)
            Protected starDotSurr.l = transColor
            Protected starDotSurrCnt.l = 1
            GdipSetPathGradientSurroundColorsWithCount(starDotBrush, @starDotSurr, @starDotSurrCnt)
            GdipFillEllipse(graphics, starDotBrush, starDotX, starDotY, starDotSz, starDotSz)
            GdipDeleteBrush(starDotBrush)
          EndIf
          GdipDeletePath(starDotPath)
        EndIf
        Protected spikeLen.f = szH * 1.2
        Protected spikeAlpha.i = ClampI(a * 0.7, 0, 255)
        Protected spikeColor.l = (spikeAlpha << 24) | (cr << 16) | (cg << 8) | cb
        Protected spikePen.i = 0
        Protected penWidth.f = 1.0
        If szH > 6.0 : penWidth = 1.5 : EndIf
        GdipCreatePen1(spikeColor, penWidth, 2, @spikePen)
        If spikePen
          GdipDrawLine(graphics, spikePen, px - spikeLen, py, px + spikeLen, py)
          GdipDrawLine(graphics, spikePen, px, py - spikeLen, px, py + spikeLen)
          Protected diagLen.f = spikeLen * 0.6
          GdipDrawLine(graphics, spikePen, px - diagLen, py - diagLen, px + diagLen, py + diagLen)
          GdipDrawLine(graphics, spikePen, px - diagLen, py + diagLen, px + diagLen, py - diagLen)
          GdipDeletePen(spikePen)
        EndIf

      Case #SHAPE_RECT
        ; Solid rotated rectangle. Uses *p\rotation (animated by tumble in
        ; UpdateParticles) to look like tumbling confetti / paper / debris.
        ; szW vs szH ratio determined by *cfg\stretch (e.g. 2.0 = ribbon).
        ; Reuses the per-frame hoisted hSolidBrush -- just retint with
        ; GdipSetSolidFillColor instead of allocating a new brush.
        If hSolidBrush
          GdipSetSolidFillColor(hSolidBrush, argbColor)
          GdipTranslateWorldTransform(graphics, px, py, 0)
          GdipRotateWorldTransform(graphics, *p\rotation, 0)
          GdipFillRectangle(graphics, hSolidBrush, -halfW, -halfH, szW, szH)
          GdipResetWorldTransform(graphics)
        EndIf


      Case #SHAPE_RING
        ; Hollow circle outline -- used for bubbles, smoke rings, sonar
        ; pulses, etc. Pen width scales with particle size so larger
        ; particles get thicker outlines (looks consistent across sizes).
        ; A second slightly-smaller, more transparent ring provides the
        ; subtle inner glow that makes a bubble look "round" rather than
        ; just a flat circle outline.
        Protected ringWidth.f = szW * 0.12   ; outline thickness (12% of diameter)
        If ringWidth < 1.5 : ringWidth = 1.5 : EndIf

        Protected ringPen.i = 0
        GdipCreatePen1(argbColor, ringWidth, #GdipUnitPixel, @ringPen)
        If ringPen
          GdipDrawEllipse(graphics, ringPen, px - halfW, py - halfH, szW, szH)
          GdipDeletePen(ringPen)
        EndIf

        ; Inner highlight ring -- 70% the size, ~40% the alpha. Subtle
        ; touch that makes the bubble read as a 3D sphere not a flat disc.
        Protected innerA.i = ClampI(Round(a * 0.4, #PB_Round_Nearest), 0, 255)
        If innerA > 0
          Protected innerARGB.l = (innerA << 24) | ((cr & $FF) << 16) | ((cg & $FF) << 8) | (cb & $FF)
          Protected innerPen.i = 0
          GdipCreatePen1(innerARGB, ringWidth * 0.5, #GdipUnitPixel, @innerPen)
          If innerPen
            Protected innerSz.f = szW * 0.7
            Protected innerHalf.f = innerSz * 0.5
            GdipDrawEllipse(graphics, innerPen, px - innerHalf, py - innerHalf, innerSz, innerSz)
            GdipDeletePen(innerPen)
          EndIf
        EndIf


      Case #SHAPE_SPRITE
        ; Skip if sprite wasn't loaded (or not in sprite mode for this frame).
        If hImageAttr = 0 Or *em\hSpriteImage = 0 Or *em\spriteW <= 0 Or *em\spriteH <= 0
          Continue
        EndIf

        ; SPRITE_SCALE: sprites need to be visibly larger than primitives
        ; (a 4-pixel circle reads as a sparkle, but a 4-pixel cloud is
        ; invisible). Multiplying the size by 4x makes sprites readable
        ; across all presets without forcing the user to crank Size 5x+.
        Protected sprScale.f = 4.0
        Protected sprW.f = szW * sprScale
        Protected sprH.f = szH * sprScale
        Protected sprHalfW.f = sprW * 0.5
        Protected sprHalfH.f = sprH * 0.5

        ; Reset only the diagonal entries -- the matrix struct is hoisted
        ; outside the loop with cm\m[24]=1.0 already set.
        cm\m[0]  = cr / 255.0
        cm\m[6]  = cg / 255.0
        cm\m[12] = cb / 255.0
        cm\m[18] = a  / 255.0

        GdipSetImageAttributesColorMatrix(hImageAttr, 0, 1, @cm, 0, 0)

        GdipTranslateWorldTransform(graphics, px, py, 0)
        GdipRotateWorldTransform(graphics, *p\rotation, 0)

        GdipDrawImageRectRectI(graphics, *em\hSpriteImage,
                                Round(-sprHalfW, #PB_Round_Nearest),
                                Round(-sprHalfH, #PB_Round_Nearest),
                                Round(sprW,      #PB_Round_Nearest),
                                Round(sprH,      #PB_Round_Nearest),
                                0, 0, *em\spriteW, *em\spriteH,
                                #GdipUnitPixel, hImageAttr, 0, 0)

        GdipResetWorldTransform(graphics)


      Default ; #SHAPE_CIRCLE
        If useGlow And szH >= 3.0
          Protected path.i = 0
          GdipCreatePath(0, @path)
          If path
            GdipAddPathEllipse(path, rectX, rectY, szW, szH)
            Protected pgBrush.i = 0
            GdipCreatePathGradientFromPath(path, @pgBrush)
            If pgBrush
              GdipSetPathGradientCenterColor(pgBrush, argbColor)
              Protected surroundColor.l = transColor
              Protected surroundCount.l = 1
              GdipSetPathGradientSurroundColorsWithCount(pgBrush, @surroundColor, @surroundCount)
              GdipFillEllipse(graphics, pgBrush, rectX, rectY, szW, szH)
              GdipDeleteBrush(pgBrush)
            EndIf
            GdipDeletePath(path)
          EndIf
        Else
          ; Solid circle fallback (no glow gradient). Reuses hoisted brush.
          If hSolidBrush
            GdipSetSolidFillColor(hSolidBrush, argbColor)
            GdipFillEllipse(graphics, hSolidBrush, rectX, rectY, szW, szH)
          EndIf
        EndIf

    EndSelect
  Next

  ; Dispose hoisted sprite-frame ImageAttributes (created once per render)
  If hImageAttr
    GdipDisposeImageAttributes(hImageAttr)
  EndIf

  ; Dispose hoisted solid brush
  If hSolidBrush
    GdipDeleteBrush(hSolidBrush)
  EndIf

  ; Additive boost (circle/glow shapes only -- sprites don't need it and
  ; the per-pixel pass is expensive on top of sprite render cost).
  If *cfg\additiveBlend And *cfg\shape <> #SHAPE_SPRITE
    Protected *pixel.Long = *em\dibBits
    Protected pixCount.i = w * h
    Protected j.i
    For j = 0 To pixCount - 1
      Protected val.l = PeekL(*pixel)
      Protected pA.i = (val >> 24) & $FF
      If pA > 0
        Protected boosted.i = pA + (pA >> 2)
        If boosted > 255 : boosted = 255 : EndIf
        PokeL(*pixel, (val & $00FFFFFF) | (boosted << 24))
      EndIf
      *pixel + 4
    Next
  EndIf

  GdipDeleteGraphics(graphics)
  SelectObject_(hdcMem, hOld)
  DeleteDC_(hdcMem)
EndProcedure

; ============================================================
;  Multimedia timer callback (runtime)
; ============================================================

; Single shared timer that ticks ALL emitters. Replaces per-emitter
; multimedia timers, which were limited by Windows' multimedia timer cap
; (default 16 per process) and consumed one timeBeginPeriod claim each.
;
; With this design, the plugin uses exactly ONE multimedia timer and ONE
; timeBeginPeriod claim regardless of how many emitters are running --
; you can have 50+ concurrent emitters with no system resource issues.
Global g_sharedTimerID.i = 0
Global g_sharedTimerActive.i = 0
Global g_mapCS.CRITICAL_SECTION  ; guards gEmitters() against concurrent access:
                                 ; the tick iterates the map (and resizes DIBs) on
                                 ; the TIMER thread while Play/Stop/MOVESIZE touch it
                                 ; on the MAIN thread.  PB maps share internal cursor
                                 ; state and reallocate on insert; unguarded overlap
                                 ; corrupts the heap (deferred ntdll access violation
                                 ; at exit).  Initialized in AttachProcess.
Global g_cbDepth.l = 0           ; >0 while SharedTimerCallback body is executing
                                 ; (Interlocked* on the timer thread; read on main)
Global g_inDetach.i = 0          ; 1 inside DLL_PROCESS_DETACH: under the OS loader
                                 ; lock only timer kills and memory frees are safe --
                                 ; no message dispatch, no Sleep/waits, no
                                 ; DestroyWindow, no GdiplusShutdown (documented AV
                                 ; sources in DllMain; OS reclaims everything at exit)
Global g_acceptingActions.i = 1  ; 0 after EXIT_RUNMODE: refuse to start new
                                 ; emitters during shutdown (a late GoSub
                                 ; dispatched from another plug-in's drain must
                                 ; not spin timers back up after our cleanup)

; ---- Title-bar auto-stop --------------------------------------------
; While the mouse cursor is inside the pub window's TITLE BAR band, ALL
; emitters are hard-stopped: overlays hidden (one-shot) and the tick
; returns immediately every frame -- zero simulation, zero rendering,
; zero window/GDI activity.  From DefWindowProc's perspective this is
; identical to NeoParticle_StopAll having been called (the proven §6
; evidence: caption buttons work the moment all per-frame activity
; ceases), so min/max/close presses and drags complete natively.  The
; emitters restart automatically the moment the cursor leaves the band.
; Reaction time is one tick (<=16 ms) -- faster than any move-and-click.
#HOVER_EXIT_MARGIN = 16          ; px of hysteresis: the cursor must leave the
                                 ; band INFLATED by this margin before the
                                 ; emitters restart, so a cursor resting on the
                                 ; band edge cannot flutter stop/start
Global g_hoverStopped.i = 0      ; 1 while emitters are auto-stopped by the
                                 ; title-bar hover rule (touched only on the
                                 ; timer thread inside the g_mapCS-held tick,
                                 ; except the reset in StopAllEmitters /
                                 ; StopSharedTimerIfIdle, which also run with
                                 ; the timer dead or the lock held)

; Real per-frame work - runs on the TIMER thread (renders + presents).
; Called only via SharedTimerCallback, which brackets it with the
; g_cbDepth in-flight counter.
Procedure SharedTimerTick()
  ; Iterate every emitter in the map and tick it.
  If MapSize(gEmitters()) = 0 : ProcedureReturn : EndIf

  ; ---- Title-bar auto-stop / auto-restart -------------------------
  ; Runs under g_mapCS (caller holds it), so the ForEach transitions
  ; below cannot collide with main-thread map mutation.  The steady-
  ; state cost is a handful of read-only USER32 queries per frame.
  ;
  ; Entry zone:  the full-width non-client TOP band of the pub root
  ;              window -- from the window's top edge down to the top
  ;              of the client area (i.e. the runtime-added title bar).
  ; Exit zone:   the same band inflated by #HOVER_EXIT_MARGIN px; the
  ;              cursor must leave the INFLATED band to restart, so
  ;              resting exactly on the boundary cannot flutter.
  Protected inZone.i = 0
  Protected hRoot.i = GetAncestor_(g_nbWinHandle, 2)
  If hRoot = 0 : hRoot = g_nbWinHandle : EndIf
  If hRoot And IsWindow_(hRoot)
    If IsIconic_(hRoot)
      ; Minimized: HOLD the current state.  No zone exists to test, and
      ; restarting here would re-show the overlays over an iconic window
      ; (the MINIMIZE handler hid them).  If we are hover-stopped, stay
      ; silent; the first tick after restore re-evaluates the real zone.
      If g_hoverStopped : ProcedureReturn : EndIf
    Else
      Protected cp.POINT
      GetCursorPos_(@cp)
      Protected rw.RECT
      GetWindowRect_(hRoot, @rw)
      Protected ctl.POINT : ctl\x = 0 : ctl\y = 0
      ClientToScreen_(hRoot, @ctl)
      If ctl\y > rw\top   ; a caption band exists (zero-height pre-npShowAppCaption)
        If g_hoverStopped = 0
          ; Not stopped: test the EXACT band.
          If cp\x >= rw\left And cp\x <= rw\right And cp\y >= rw\top And cp\y < ctl\y
            inZone = 1
          EndIf
        Else
          ; Already stopped: test the INFLATED band (hysteresis).
          If cp\x >= rw\left - #HOVER_EXIT_MARGIN And cp\x <= rw\right + #HOVER_EXIT_MARGIN And cp\y >= rw\top - #HOVER_EXIT_MARGIN And cp\y < ctl\y + #HOVER_EXIT_MARGIN
            inZone = 1
          EndIf
        EndIf
      EndIf
    EndIf
  EndIf

  If inZone
    If g_hoverStopped = 0
      ; ---- STOP: one-shot transition ----
      ; Hide every overlay (the flames vanish), then go silent.  The
      ; hides are the last window operations until restart; from this
      ; frame on the tick returns immediately, so caption-button press
      ; tracking on the pub window runs with ZERO interference.
      g_hoverStopped = 1
      ForEach gEmitters()
        If gEmitters()\hOverlay And IsWindow_(gEmitters()\hOverlay)
          ShowWindow_(gEmitters()\hOverlay, #SW_HIDE)
        EndIf
      Next
    EndIf
    ProcedureReturn   ; stopped: NOTHING happens while the cursor is in the band
  ElseIf g_hoverStopped
    ; ---- RESTART: one-shot transition ----
    ; Cursor left the inflated band: re-show the overlays and resume.
    ; lastTickCount is rebased to NOW so the first resumed frame gets a
    ; normal ~16 ms dt instead of a catch-up jump (the 100 ms clamp
    ; would bound it anyway; this makes the resume seamless).
    g_hoverStopped = 0
    ForEach gEmitters()
      If gEmitters()\running And gEmitters()\hOverlay And IsWindow_(gEmitters()\hOverlay)
        ShowWindow_(gEmitters()\hOverlay, #SW_SHOWNOACTIVATE)
        gEmitters()\lastTickCount = GetTickCount_()
      EndIf
    Next
  EndIf

  ForEach gEmitters()
    Protected *em.ParticleEmitter = @gEmitters()
    If *em\running = 0 : Continue : EndIf
    If *em\hDIB = 0 Or *em\dibBits = 0 : Continue : EndIf
    If *em\hOverlay = 0 : Continue : EndIf
    If *em\dibW <= 0 Or *em\dibH <= 0 : Continue : EndIf

    ; ---- Live host-size sync (resolution-independent alignment) ----------
    ; The DIB is sized to the host's client rect. If we only resized it on the
    ; #NBMSG_MOVESIZE event, the DIB would stay frozen whenever the host is
    ; resized by a path that does NOT raise that event for us -- notably when
    ; NeoFit repositions/resizes the host via SetWindowPos during its layout
    ; pass. A frozen DIB then mismatches the live overlay window: PresentOverlay
    ; blits the DIB (sized at the OLD, e.g. design, width) into a window sized to
    ; the NEW live width. Because UpdateLayeredWindow maps the source 1:1 (no
    ; scaling), the particle column -- rendered centered in the old-width DIB --
    ; no longer sits at the live window's centre. Its horizontal offset is
    ; (dibW_old - liveW)/2, i.e. proportional to (1 - uniformScale): zero at the
    ; launch resolution, growing as the window shrinks, reversing when it grows.
    ; That is the horizontal flame drift.
    ;
    ; Fix: poll the live host client rect EVERY frame (exactly as NeoOverlay's
    ; LayerRender does) and rebuild the DIB whenever it changed. EnsureDIB
    ; early-returns when the size is unchanged, so this is near-free in the
    ; steady state and only reallocates on an actual resolution change.
    If IsWindow_(*em\hHost)
      Protected rcLive.RECT
      GetClientRect_(*em\hHost, @rcLive)
      Protected liveW.i = rcLive\right  - rcLive\left
      Protected liveH.i = rcLive\bottom - rcLive\top
      If liveW >= 1 And liveH >= 1
        If *em\dibW <> liveW Or *em\dibH <> liveH
          If EnsureDIB(*em, liveW, liveH)
            *em\lastHostW = liveW
            *em\lastHostH = liveH
            ; IMPORTANT: do NOT touch refH here. refH is the box height captured
            ; at first spawn; the motion code uses velScale = dibH / refH so that
            ; when the box changes size, velocity covers the SAME fraction of the
            ; box (resolution-independent motion). Holding refH fixed is exactly
            ; what makes that work. Rebaselining refH to the live height on every
            ; resize forces velScale = 1.0 (defeating the feature) and, if the
            ; capture ever lands on a transient/wrong host size during a relaunch
            ; or layout reflow, pins velScale at the wrong ratio -- which makes
            ; particles rise too little (a short, squat flame) for the rest of the
            ; run. The DIB resize above is the alignment fix; refH/velScale must
            ; stay independent of it.
          Else
            ; Reallocation failed (extremely rare). Skip this emitter's frame;
            ; the old DIB has already been freed by EnsureDIB->FreeDIB, so the
            ; guards below (hDIB/dibBits/dibW) will short-circuit safely.
            Continue
          EndIf
        EndIf
      EndIf
    EndIf
    ; Re-validate after a possible resize before any further work this frame.
    If *em\hDIB = 0 Or *em\dibBits = 0 : Continue : EndIf
    If *em\dibW <= 0 Or *em\dibH <= 0 : Continue : EndIf

    Protected now.i = GetTickCount_()
    Protected elapsed.i = now - *em\lastTickCount
    If elapsed <= 0 : elapsed = 16 : EndIf
    If elapsed > 100 : elapsed = 100 : EndIf
    *em\lastTickCount = now
    Protected dt.f = elapsed / 1000.0
    *em\timeAcc + dt

    UpdateParticles(*em, dt)

    If *em\burstDone And *em\activeCount = 0
      *em\running = 0
      Continue
    EndIf

    If *em\running = 0 Or *em\hDIB = 0 Or *em\dibBits = 0 : Continue : EndIf

    RenderParticles(*em)

    If *em\running And *em\hOverlay And IsWindow_(*em\hOverlay)
      PresentOverlay(*em)
    EndIf
  Next
EndProcedure

; The actual timeSetEvent callback.  Wraps SharedTimerTick with an
; interlocked in-flight counter so shutdown can WAIT for a tick that is
; already executing.  Why this matters: PresentOverlay calls
; SetWindowPos/UpdateLayeredWindow on windows OWNED BY THE MAIN THREAD.
; A cross-thread SetWindowPos blocks until the owning thread processes
; the sent message.  If shutdown kills the timer and proceeds while a
; tick is blocked there, ExitProcess later terminates this thread while
; it holds a user32/winmm internal lock -> process cleanup deadlocks ->
; the exe never fully exits (the intermittent task-manager-kill hang).
Procedure SharedTimerCallback(uTimerID.i, uMsg.i, dwUser.i, dw1.i, dw2.i)
  ; TIMER-THREAD side of the emitter-map lock: never block, never wait.
  ; If the main thread is mutating the map (Play adding an element,
  ; Stop removing one, MOVESIZE resizing DIBs), simply skip this frame;
  ; the next tick is 16 ms away and a single dropped frame is invisible.
  If TryEnterCriticalSection_(@g_mapCS) = 0
    ProcedureReturn
  EndIf
  InterlockedIncrement_(@g_cbDepth)
  SharedTimerTick()
  InterlockedDecrement_(@g_cbDepth)
  LeaveCriticalSection_(@g_mapCS)
EndProcedure

Procedure StartSharedTimerIfNeeded()
  If g_sharedTimerActive : ProcedureReturn : EndIf
  timeBeginPeriod(1)
  g_sharedTimerID = timeSetEvent(#PARTICLE_TIMER_MS, 1, @SharedTimerCallback(), 0,
                                 #TIME_PERIODIC | #TIME_CALLBACK_FUNCTION)
  If g_sharedTimerID
    g_sharedTimerActive = 1
  Else
    ; Could not create timer (extremely rare -- system out of mm timers).
    ; Release the timer-period claim and leave inactive.
    timeEndPeriod(1)
  EndIf
EndProcedure

; Stop the shared timer when no emitters are left. Called from
; StopEmitter/StopAllEmitters after the last emitter is removed.
Procedure StopSharedTimerIfIdle()
  If g_sharedTimerActive = 0 : ProcedureReturn : EndIf
  If MapSize(gEmitters()) > 0 : ProcedureReturn : EndIf
  timeKillEvent(g_sharedTimerID)
  timeEndPeriod(1)
  g_sharedTimerID = 0
  g_sharedTimerActive = 0
  g_hoverStopped = 0   ; title-bar auto-stop state dies with the timer:
                       ; a later Play session must start un-stopped
EndProcedure

; Legacy per-emitter TimerCallback removed; replaced by SharedTimerCallback.
; Kept as a stub in case any reference to it lingers anywhere in the
; codebase -- it's a no-op now.
Procedure TimerCallback(uTimerID.i, uMsg.i, dwUser.i, dw1.i, dw2.i)
  ProcedureReturn
EndProcedure

; ============================================================
;  Emitter start / stop
; ============================================================

; ------------------------------------------------------------
; Safe message drain: dispatches queued messages WITHOUT ever
; consuming the host's WM_QUIT.
;
; PeekMessage with hwnd=0 retrieves ALL thread messages,
; INCLUDING WM_QUIT (which is only retrievable with the null
; filter).  DispatchMessage on WM_QUIT is a silent no-op, so an
; unfiltered drain during shutdown can eat the quit message that
; VisualNEO's main loop is waiting for -- the window closes but
; the PROCESS stays alive.  This was the intermittent
; "VNW did not shut down completely" hang: with three plug-ins
; each pumping at exit, sooner or later one drain ran while
; WM_QUIT was already queued.  If we ever pull WM_QUIT here we
; re-post it and stop draining immediately.
; ------------------------------------------------------------
; Wait for any in-flight timer callback to finish - WITHOUT deadlocking.
; A tick blocked in a cross-thread SetWindowPos is waiting for OUR
; thread to process a sent message.  PeekMessage with PM_NOREMOVE
; delivers pending SENT messages without consuming any POSTED message
; (so it can never swallow WM_QUIT) - that delivery is what unblocks
; the callback so it can run to completion and drop g_cbDepth to 0.
; Bounded at ~500ms so a pathological state can never hang shutdown.
Procedure WaitForCallbackExit()
  ; At process detach the timer threads are already terminated by the OS;
  ; g_cbDepth may be frozen non-zero by a killed thread.  Waiting (or
  ; delivering sent messages) under the loader lock is pointless/unsafe.
  If g_inDetach : ProcedureReturn : EndIf
  Protected m.MSG
  Protected tries.i = 0
  While g_cbDepth > 0 And tries < 50
    PeekMessage_(@m, 0, 0, 0, #PM_NOREMOVE)   ; deliver sent messages only
    Sleep_(10)
    tries + 1
  Wend
EndProcedure

; Main-thread side of the emitter-map lock.  TryEnter in a loop that
; DELIVERS PENDING SENT MESSAGES (PM_NOREMOVE) between attempts: the
; tick may be holding the lock while blocked in a cross-thread
; SetWindowPos/UpdateLayeredWindow send to THIS thread -- a plain
; EnterCriticalSection here would deadlock (we wait on the lock, the
; tick waits on us).  Delivering the sends lets the tick finish and
; release; a tick lasts ~1-2 ms, so this resolves within a frame.
; CRITICAL_SECTION is reentrant per-thread, so nested acquisition
; (CreateEmitterShell -> StopEmitter) is safe with balanced releases.
Procedure AcquireMapLock()
  If g_inDetach : ProcedureReturn : EndIf   ; timer threads already gone
  Protected m.MSG
  While TryEnterCriticalSection_(@g_mapCS) = 0
    PeekMessage_(@m, 0, 0, 0, #PM_NOREMOVE)   ; deliver sent messages only
    Sleep_(1)
  Wend
EndProcedure

Procedure ReleaseMapLock()
  If g_inDetach : ProcedureReturn : EndIf
  LeaveCriticalSection_(@g_mapCS)
EndProcedure

Procedure SafeDrainMessages(maxCount.i)
  If g_inDetach : ProcedureReturn : EndIf   ; never dispatch under the loader lock
  Protected m.MSG
  Protected n.i = 0
  While PeekMessage_(@m, 0, 0, 0, #PM_REMOVE) And n < maxCount
    If m\message = #WM_QUIT
      PostQuitMessage_(m\wParam)   ; put it back for VNW's main loop
      Break
    EndIf
    TranslateMessage_(@m)
    DispatchMessage_(@m)
    n + 1
  Wend
EndProcedure

Procedure StopEmitter(key.s)
  ; Comprehensive shutdown sequence for a single emitter.
  ;
  ; Order matters here -- each step has a reason:
  ;   1. running=0 first  -> tells any in-flight render callback to bail
  ;                          immediately (PresentOverlay also has this guard)
  ;   2. kill timer       -> stops new callbacks from firing
  ;   3. Sleep(150)       -> waits for any callback that was JUST fired
  ;                          before the kill to finish its work safely.
  ;                          150ms covers heavy-render frames; smaller waits
  ;                          can let a destroy-while-rendering crash slip
  ;                          through.
  ;   4. hide+destroy overlay -> removes the layered window from the screen
  ;   5. delete DIB      -> frees the 4-byte-per-pixel pre-multiplied buffer
  ;   6. release sprite  -> frees GDI+ image handle if loaded
  ;   7. zero state      -> defensive; stops any ghost-data confusion if
  ;                          the same key is later reused
  ;   8. remove from map -> PB also frees struct strings on DeleteMapElement

  AcquireMapLock()
  If FindMapElement(gEmitters(), key) = 0
    ReleaseMapLock()
    SetLastError("Stop: no active emitter on rectangle '" + key + "'")
    ProcedureReturn
  EndIf

  Protected *em.ParticleEmitter = @gEmitters()

  ; ---- Step 1: Signal abort to any rendering callback ----
  ; With the shared timer, just setting running=0 is enough -- the shared
  ; callback iterates all emitters and skips those marked not-running.
  *em\running = 0
  *em\timerID = 0   ; legacy field; shared timer doesn't use it

  ; ---- Step 3: Wait for any in-flight callback to finish ----
  ; Handshake on g_cbDepth instead of a blind Sleep: delivers pending
  ; cross-thread sent messages so a tick blocked in SetWindowPos can
  ; complete instead of being abandoned mid-flight (shutdown deadlock).
  WaitForCallbackExit()

  ; ---- Step 4: Hide + destroy the layered overlay window ----
  Protected savedOverlay.i = *em\hOverlay
  *em\hOverlay = 0
  If savedOverlay And IsWindow_(savedOverlay) And g_inDetach = 0
    ShowWindow_(savedOverlay, #SW_HIDE)
    DestroyWindow_(savedOverlay)
    ; Drain pending Windows messages so the destruction actually completes
    ; synchronously. Without this, DestroyWindow can be queued and the
    ; window stays alive in the message queue until VNW pumps it -- meaning
    ; the second Play creates an overlay while the first is still visible.
    ; (WM_QUIT-safe drain -- never eats the host's quit message.)
    SafeDrainMessages(64)
  EndIf

  ; ---- Step 5: Delete DIB (rendering buffer) ----
  Protected savedDIB.i = *em\hDIB
  *em\hDIB = 0
  *em\dibBits = 0
  If savedDIB
    DeleteObject_(savedDIB)
  EndIf
  *em\dibW = 0
  *em\dibH = 0
  *em\refH = 0

  ; ---- Step 6: Release sprite (GDI+ image, if loaded) ----
  ReleaseEmitterSprite(*em)

  ; ---- Step 7: Defensive state zeroing ----
  *em\activeCount = 0
  *em\spawnAccum = 0.0
  *em\timeAcc = 0.0
  *em\burstDone = 0
  *em\hHost = 0
  Protected pi.i
  For pi = 0 To #MAX_PARTICLES - 1
    *em\particles[pi]\alive = 0
  Next

  ; ---- Step 8: Remove from map ----
  ; If this was the last (or only) emitter, ClearMap the whole thing
  ; instead of removing just one element. ClearMap is more thorough --
  ; it frees ALL slot memory and gives PB a chance to fully reset the
  ; map's internal hash table. DeleteMapElement merely marks the slot
  ; free, which on subsequent AddMapElement can hand back the same
  ; memory address with stale contents in fields we don't explicitly
  ; set. ClearMap avoids that entirely when we're shutting down completely.
  If MapSize(gEmitters()) <= 1
    ClearMap(gEmitters())
  Else
    DeleteMapElement(gEmitters(), key)
  EndIf

  ; If this was the last emitter, stop the shared timer too. Frees the
  ; multimedia timer slot and releases the system timer-precision claim.
  StopSharedTimerIfIdle()
  ReleaseMapLock()
EndProcedure

; Comprehensive shutdown of every emitter. Designed to leave NO threads,
; timers, windows, or GDI handles alive when complete. Safe to call multiple
; times; safe during plugin shutdown.
;
; Order of operations matters for proper cleanup:
;   1. Flag every emitter as not-running so any in-flight timer callbacks
;      bail out immediately on their next tick
;   2. Kill all multimedia timers AND release the timer-precision claims
;      (every timeBeginPeriod must have a matching timeEndPeriod or the
;      system's timer resolution stays elevated -> process can hang at exit)
;   3. Sleep generously to give any in-flight callbacks time to finish
;   4. Hide and destroy all overlay windows
;   5. Pump the message queue so DestroyWindow actually completes
;      synchronously (DestroyWindow merely *posts* WM_DESTROY; the window
;      stays alive in the message queue until pumped)
;   6. Free DIBs (GDI bitmaps)
;   7. Release sprite images (GDI+ objects)
;   8. ClearMap to release struct memory
;   9. Second message pump to flush anything left
;   10. GdiplusShutdown if the plugin is going away (handled in
;       _nbMessage(#NBMSG_EXIT_RUNMODE); this proc doesn't call it because
;       it can be called mid-session to stop everything without exiting)
Procedure StopAllEmitters()
  If MapSize(gEmitters()) = 0
    ; Even with no emitters, drain messages so any straggler callbacks
    ; or pending window destructions get flushed.
    ; (WM_QUIT-safe drain -- never eats the host's quit message.)
    SafeDrainMessages(64)
    ProcedureReturn
  EndIf

  ; ---- Step 1: kill the shared timer FIRST ----
  ; This ensures no new ticks fire during cleanup. With per-emitter timers
  ; we had to kill each individually; now it's one call.
  If g_sharedTimerActive
    timeKillEvent(g_sharedTimerID)
    timeEndPeriod(1)
    g_sharedTimerID = 0
    g_sharedTimerActive = 0
  EndIf
  g_hoverStopped = 0   ; full teardown clears the title-bar auto-stop state

  ; ---- Step 2: take the map lock, THEN flag every emitter ----
  ; CRITICAL ORDER: timeKillEvent does NOT wait for an in-flight
  ; callback to return -- a tick fired just before the kill can still
  ; be mid-ForEach on the TIMER thread, holding g_mapCS.  PB maps share
  ; one internal cursor, so the flagging ForEach below MUST NOT run
  ; concurrently with the tick's ForEach (cursor + heap corruption,
  ; surfacing as the intermittent at-exit hang/AV).  Acquire the lock
  ; first: it contends for at most one render time, excludes the
  ; in-flight tick, and prevents any straggler from entering
  ; (TryEnter-or-skip on the timer side).
  AcquireMapLock()

  ForEach gEmitters()
    gEmitters()\running = 0
    gEmitters()\timerID = 0   ; legacy field; no longer used
  Next

  ; ---- Step 3: wait for any in-flight callback to finish ----
  ; With the lock held no tick body can be running; this handshake
  ; covers only the brief window where the callback passed TryEnter
  ; but has not yet raised g_cbDepth.  Resolves instantly; the tick
  ; never waits on the lock (TryEnter-or-skip), so holding it here
  ; cannot deadlock.
  WaitForCallbackExit()

  ; (Map lock already held since Step 2 -- one acquisition spans the
  ; whole teardown, balanced by the single ReleaseMapLock below.  A
  ; second acquire here would leave the CS owned forever: the CS is
  ; reentrant for THIS thread, but the timer thread's TryEnter would
  ; fail on every frame of any later Play session -- frozen emitters.)

  ; ---- Step 4+5: destroy overlay windows + pump messages ----
  ForEach gEmitters()
    Protected savedOverlay.i = gEmitters()\hOverlay
    gEmitters()\hOverlay = 0
    If savedOverlay And IsWindow_(savedOverlay) And g_inDetach = 0
      ShowWindow_(savedOverlay, #SW_HIDE)
      DestroyWindow_(savedOverlay)
    EndIf

    ; ---- Step 6: free DIB ----
    Protected savedDIB.i = gEmitters()\hDIB
    gEmitters()\hDIB = 0
    gEmitters()\dibBits = 0
    gEmitters()\dibW = 0
    gEmitters()\dibH = 0
    gEmitters()\refH = 0
    If savedDIB
      DeleteObject_(savedDIB)
    EndIf

    ; ---- Step 7: release sprite ----
    ReleaseEmitterSprite(@gEmitters())

    ; Defensive zeroing -- subsequent proc shouldn't see any stale data.
    gEmitters()\hHost = 0
    gEmitters()\activeCount = 0
    gEmitters()\spawnAccum = 0.0
    gEmitters()\timeAcc = 0.0
    gEmitters()\burstDone = 0
    Protected pi.i
    For pi = 0 To #MAX_PARTICLES - 1
      gEmitters()\particles[pi]\alive = 0
    Next
  Next

  ; Drain all queued window destruction + paint messages BEFORE we delete
  ; the struct memory. If we ClearMap first, the WM_DESTROY callbacks
  ; would dispatch on freed memory.
  ; (WM_QUIT-safe drain -- never eats the host's quit message.)
  SafeDrainMessages(128)

  ; ---- Step 8: release struct memory ----
  ClearMap(gEmitters())

  ReleaseMapLock()

  ; ---- Step 9: final message drain ----
  ; Catches anything that ClearMap or the final GDI releases queued.
  ; (WM_QUIT-safe drain -- never eats the host's quit message.)
  SafeDrainMessages(64)
EndProcedure

; ============================================================
;  Config override helpers (composable; called after ApplyPreset)
; ============================================================

; Intensity 0-100 (50 = preset default; 100 = 2x; 0 = off).
Procedure ApplyIntensity(*cfg.EmitterConfig, intensity.i)
  intensity = ClampI(intensity, 0, 100)
  Protected scale.f = intensity / 50.0   ; 0.0 .. 2.0  (50 -> 1.0)
  *cfg\spawnRate * scale
  *cfg\burstCount = Round(*cfg\burstCount * scale, #PB_Round_Nearest)
EndProcedure

; "R,G,B" -> override start colour. Empty string = leave preset value.
Procedure ApplyColorStart(*cfg.EmitterConfig, colorStr.s)
  If colorStr = "" : ProcedureReturn : EndIf
  Protected r.l, g.l, b.l
  If ParseColorString(colorStr, @r, @g, @b)
    *cfg\colorStartR = r : *cfg\colorStartG = g : *cfg\colorStartB = b
    *cfg\colorOverride = 1
  EndIf
EndProcedure

; "R,G,B" -> override end colour. Empty string = leave preset value.
Procedure ApplyColorEnd(*cfg.EmitterConfig, colorStr.s)
  If colorStr = "" : ProcedureReturn : EndIf
  Protected r.l, g.l, b.l
  If ParseColorString(colorStr, @r, @g, @b)
    *cfg\colorEndR = r : *cfg\colorEndG = g : *cfg\colorEndB = b
    *cfg\colorOverride = 1
  EndIf
EndProcedure

; "R,G,B" -> override mid colour AND enable the 3-stop gradient.
; "none" / "off" / "0" -> disable the 3-stop midpoint entirely, falling
; back to a smooth 2-stop start->end gradient. Useful when overriding
; the start/end colors of a 3-stop preset (e.g. AuroraBorealis) where
; the preset's built-in midpoint clashes with the new color theme.
Procedure ApplyColorMid(*cfg.EmitterConfig, colorStr.s)
  If colorStr = "" : ProcedureReturn : EndIf
  Protected lcStr.s = LCase(Trim(colorStr))
  If lcStr = "none" Or lcStr = "off" Or lcStr = "0"
    *cfg\hasColorMid = 0
    ProcedureReturn
  EndIf
  Protected r.l, g.l, b.l
  If ParseColorString(colorStr, @r, @g, @b)
    *cfg\colorMidR = r : *cfg\colorMidG = g : *cfg\colorMidB = b
    *cfg\hasColorMid = 1
    *cfg\colorOverride = 1
  EndIf
EndProcedure

; Simple-Play behaviour: solid override (start=end=given colour).
Procedure ApplyIntensityAndColor(*cfg.EmitterConfig, intensity.i, colorStr.s)
  ApplyIntensity(*cfg, intensity)
  If colorStr <> ""
    ApplyColorStart(*cfg, colorStr)
    ApplyColorEnd(*cfg, colorStr)
  EndIf
EndProcedure

; Apply non-colour advanced overrides to an emitter that has already had
; ApplyPreset (and optionally ApplyIntensity / ApplyColorStart / ApplyColorEnd).
;
;   emitAreaStr  "X,Y,W,H"  all 0..1   ("" keeps preset)
;   dirStr       "angle,spread"        ("" keeps preset)
;   speedMulStr, sizeMulStr, lifeMulStr   floats; ""=1.0
;   modeStr      "" / Continuous / Continuous:R / Burst / Burst:N
;   zOrderStr    "" / Front / Behind
;   opacityStr   ""  or 0.0..1.0  (multiplier applied to alphaStart and alphaEnd)
Procedure ApplyAdvancedNonColor(*em.ParticleEmitter,
                                 emitAreaStr.s, dirStr.s,
                                 speedMulStr.s, sizeMulStr.s, lifeMulStr.s,
                                 modeStr.s, zOrderStr.s, opacityStr.s)
  Protected *cfg.EmitterConfig = @*em\config

  ; ----- Emit area (X,Y,W,H all 0..1) -----
  If emitAreaStr <> ""
    Protected ex.s = Trim(StringField(emitAreaStr, 1, ","))
    Protected ey.s = Trim(StringField(emitAreaStr, 2, ","))
    Protected ew.s = Trim(StringField(emitAreaStr, 3, ","))
    Protected eh.s = Trim(StringField(emitAreaStr, 4, ","))
    If ex <> "" : *cfg\emitX = ClampF(ValF(ex), 0.0, 1.0) : EndIf
    If ey <> "" : *cfg\emitY = ClampF(ValF(ey), 0.0, 1.0) : EndIf
    ; Allow W/H slightly >1 so e.g. snow can spawn just outside the rect edges
    If ew <> "" : *cfg\emitW = ClampF(ValF(ew), 0.0, 2.0) : EndIf
    If eh <> "" : *cfg\emitH = ClampF(ValF(eh), 0.0, 2.0) : EndIf
  EndIf

  ; ----- Direction (angle,spread) in degrees -----
  If dirStr <> ""
    Protected da.s = Trim(StringField(dirStr, 1, ","))
    Protected ds.s = Trim(StringField(dirStr, 2, ","))
    If da <> "" : *cfg\baseAngle   = ValF(da)                         : EndIf
    If ds <> "" : *cfg\spreadAngle = ClampF(ValF(ds), 0.0, 360.0)     : EndIf
  EndIf

  ; ----- Speed multiplier -----
  ; sm >= 0 is valid (0 = freeze particles in place). Negative is ignored.
  If speedMulStr <> ""
    Protected sm.f = ValF(speedMulStr)
    If sm >= 0.0
      *cfg\speedMin * sm : *cfg\speedMax * sm
    EndIf
  EndIf

  ; ----- Size multiplier (scales both birth and death size) -----
  ; zm = 0 makes particles invisible (size 0). Useful for fading effects.
  If sizeMulStr <> ""
    Protected zm.f = ValF(sizeMulStr)
    If zm >= 0.0
      *cfg\sizeStartMin * zm : *cfg\sizeStartMax * zm
      *cfg\sizeEndMin   * zm : *cfg\sizeEndMax   * zm
    EndIf
  EndIf

  ; ----- Lifetime multiplier -----
  ; lm = 0 makes particles die immediately on spawn (no visible life).
  If lifeMulStr <> ""
    Protected lm.f = ValF(lifeMulStr)
    If lm >= 0.0
      *cfg\lifeMin * lm : *cfg\lifeMax * lm
    EndIf
  EndIf

  ; ----- Mode override (Continuous / Continuous:R / Burst / Burst:N) -----
  If modeStr <> ""
    Protected mTrim.s = Trim(modeStr)
    Protected colonPos.i = FindString(mTrim, ":", 1)
    Protected modeKey.s
    Protected modeArg.s = ""
    If colonPos > 0
      modeKey = LCase(Trim(Left(mTrim, colonPos - 1)))
      modeArg = Trim(Mid(mTrim, colonPos + 1))
    Else
      modeKey = LCase(mTrim)
    EndIf

    Select modeKey
      Case "burst"
        *cfg\spawnRate = 0.0
        If modeArg <> ""
          Protected bn.i = Val(modeArg)
          If bn > 0 : *cfg\burstCount = bn : EndIf
        ElseIf *cfg\burstCount = 0
          *cfg\burstCount = 200
        EndIf

      Case "continuous"
        *cfg\burstCount = 0
        If modeArg <> ""
          Protected cr.f = ValF(modeArg)
          If cr > 0.0 : *cfg\spawnRate = cr : EndIf
        ElseIf *cfg\spawnRate <= 0.0
          *cfg\spawnRate = 30.0
        EndIf
    EndSelect
  EndIf

  ; ----- Z-Order -----
  If LCase(Trim(zOrderStr)) = "behind"
    *em\topMost = 0
  Else
    *em\topMost = 1
  EndIf

  ; ----- Opacity (multiplier 0.0 .. 1.0 applied to alphaStart and alphaEnd) -----
  If opacityStr <> ""
    Protected op.f = ClampF(ValF(opacityStr), 0.0, 1.0)
    *cfg\alphaStart * op
    *cfg\alphaEnd   * op
  EndIf
EndProcedure

; ============================================================
;  Options string helpers (key=value;key=value;...)
;
;  PlayAdvanced packs all the optional knobs into a single VNW
;  parameter so the action stays at 4 params on the wire (rect,
;  preset, intensity, options) which keeps it well within whatever
;  VNW's per-action parameter cap turns out to be.
; ============================================================

; Look up a key in a "k1=v1;k2=v2;..." string. Returns the value or "".
Procedure.s ParseOption(options.s, key.s)
  If options = "" Or key = "" : ProcedureReturn "" : EndIf
  Protected lcKey.s = LCase(Trim(key))
  Protected count.i = CountString(options, ";") + 1
  Protected i.i
  For i = 1 To count
    Protected pair.s = Trim(StringField(options, i, ";"))
    If pair = "" : Continue : EndIf
    Protected eqPos.i = FindString(pair, "=", 1)
    If eqPos > 0
      Protected k.s = LCase(Trim(Left(pair, eqPos - 1)))
      Protected v.s = Trim(Mid(pair, eqPos + 1))
      If k = lcKey : ProcedureReturn v : EndIf
    EndIf
  Next
  ProcedureReturn ""
EndProcedure

; Append "key=value" to an options string (or start a new one). Empty value
; is skipped so the resulting string only contains explicitly-set options.
Procedure.s AppendKV(existing.s, key.s, value.s)
  If value = "" : ProcedureReturn existing : EndIf
  If existing = ""
    ProcedureReturn key + "=" + value
  EndIf
  ProcedureReturn existing + ";" + key + "=" + value
EndProcedure

; Apply every supported option from the options string to *em.
; Called after ApplyPreset + ApplyIntensity.
Procedure ApplyAdvancedFromOptions(*em.ParticleEmitter, options.s)
  ApplyColorStart(@*em\config, ParseOption(options, "colorStart"))
  ApplyColorEnd  (@*em\config, ParseOption(options, "colorEnd"))
  ApplyColorMid  (@*em\config, ParseOption(options, "colorMid"))
  ApplyAdvancedNonColor(*em,
                         ParseOption(options, "emitArea"),
                         ParseOption(options, "direction"),
                         ParseOption(options, "speed"),
                         ParseOption(options, "size"),
                         ParseOption(options, "life"),
                         ParseOption(options, "mode"),
                         ParseOption(options, "zOrder"),
                         ParseOption(options, "opacity"))

  ; Density: multiplier on spawnRate AND burstCount. Applied AFTER mode
  ; override so it scales whatever mode set (e.g. Burst:200 + density=2 = 400).
  ; Range typically 0.0 .. 10.0; 0 stops new spawns, "" leaves preset alone.
  Protected densityStr.s = ParseOption(options, "density")
  If densityStr <> ""
    Protected d.f = ValF(densityStr)
    If d >= 0.0
      *em\config\spawnRate * d
      *em\config\burstCount = Round(*em\config\burstCount * d, #PB_Round_Nearest)
    EndIf
  EndIf

  ; Twinkle: multiplier on the brightness-modulation rate. Applies to BOTH
  ; twinkleSpeed (used by Stars, Glow) and pulseSpeed (used by LightBeams,
  ; GodRays, Laser). 0 disables modulation entirely (constant brightness).
  Protected twinkleStr.s = ParseOption(options, "twinkle")
  If twinkleStr <> ""
    Protected t.f = ValF(twinkleStr)
    If t >= 0.0
      *em\config\twinkleSpeed * t
      *em\config\pulseSpeed   * t
    EndIf
  EndIf

  ; Feather: edge-fade fraction (0.0..0.5). Overrides preset's feather
  ; entirely (not a multiplier). 0 = no feathering (hard rect edges).
  ; Higher values produce wider edge-fade zones. Useful when emitting
  ; particles across full rect area where the rect outline would otherwise
  ; show against the background.
  Protected featherStr.s = ParseOption(options, "feather")
  If featherStr <> ""
    Protected fv.f = ValF(featherStr)
    If fv < 0.0 : fv = 0.0 : EndIf
    If fv > 1.0 : fv = 1.0 : EndIf
    *em\config\feather = fv
  EndIf

  ; Taper: shape spawn region as a trapezoid (narrow at top, wide at
  ; bottom). 0..1 where 1=no taper (default), 0.5=top is half as wide,
  ; 0.0=top is a point. Useful for moonlight reflections, fountain
  ; spray fans, etc. where a rectangular spawn looks unnatural.
  Protected taperStr.s = ParseOption(options, "taper")
  If taperStr <> ""
    Protected tv.f = ValF(taperStr)
    If tv < 0.0 : tv = 0.0 : EndIf
    If tv > 1.0 : tv = 1.0 : EndIf
    *em\config\taper = tv
  EndIf

  ; taperEdge: which edge is the narrow one. Accepts name (top/bottom/
  ; left/right) or index (0/1/2/3). Top is default.
  Protected taperEdgeStr.s = LCase(Trim(ParseOption(options, "taperEdge")))
  If taperEdgeStr <> ""
    Select taperEdgeStr
      Case "top",    "0" : *em\config\taperEdge = 0
      Case "bottom", "1" : *em\config\taperEdge = 1
      Case "left",   "2" : *em\config\taperEdge = 2
      Case "right",  "3" : *em\config\taperEdge = 3
    EndSelect
  EndIf

  ; Orientation: switches streak direction by rotating baseAngle. Mainly
  ; for the Shimmer preset (vertical reflection column vs horizontal
  ; wavelet streaks), but works for any directional preset.
  ;   "horizontal" -> rotate to horizontal (0 deg = rightward), particles
  ;                   render as horizontal streaks; for shimmer this
  ;                   matches wavelet ripples parallel to water surface.
  ;   "vertical"   -> keep preset's baseAngle (default for shimmer is up).
  Protected orientStr.s = LCase(Trim(ParseOption(options, "orientation")))
  If orientStr = "horizontal"
    *em\config\baseAngle = 0.0     ; rightward; spread keeps it gentle
  ElseIf orientStr = "vertical"
    ; explicit vertical - keep preset baseAngle as-is (default behaviour)
  EndIf

  ; Shape override: replaces the preset's particle shape entirely.
  ; Useful for Confetti as Rectangle, Sparkle as Star, Snow as Rect, etc.
  ; Recognised values (case-insensitive): Circle, Rectangle, Star, Glow,
  ; Ray, Beam, Laser, LightBurst, Sprite.
  Protected shapeStr.s = LCase(Trim(ParseOption(options, "shape")))
  If shapeStr <> ""
    Select shapeStr
      Case "circle"            : *em\config\shape = #SHAPE_CIRCLE
      Case "rectangle", "rect" : *em\config\shape = #SHAPE_RECT
      Case "star"              : *em\config\shape = #SHAPE_STAR
      Case "glow"              : *em\config\shape = #SHAPE_GLOW
      Case "ray"               : *em\config\shape = #SHAPE_RAY
      Case "beam"              : *em\config\shape = #SHAPE_BEAM
      Case "laser"             : *em\config\shape = #SHAPE_LASER
      Case "lightburst"        : *em\config\shape = #SHAPE_LIGHTBURST
      Case "sprite"            : *em\config\shape = #SHAPE_SPRITE
      Case "ring"              : *em\config\shape = #SHAPE_RING
    EndSelect
  EndIf

  ; Sprite path: only meaningful when shape ends up as #SHAPE_SPRITE, but
  ; we load it whenever a path is supplied so a user can swap shapes back
  ; and forth without re-loading. LoadEmitterSprite is idempotent.
  Protected spritePath.s = ParseOption(options, "sprite")
  If spritePath <> ""
    LoadEmitterSprite(*em, spritePath)
  EndIf
EndProcedure

; ============================================================
;  Emitter launch helpers
; ============================================================

; Allocate DIB + overlay, fire burst if needed, start the multimedia timer.
; Cleans up and removes the map entry on any failure. The map key is
; recomputed from *em\rectName so callers don't have to thread it through.
Procedure.i FinalizeEmitter(*em.ParticleEmitter, w.i, h.i)
  Protected key.s = LCase(Trim(*em\rectName))

  If EnsureDIB(*em, w, h) = 0
    SetLastError("Failed to create DIB for particles.")
    AcquireMapLock()
    DeleteMapElement(gEmitters(), key)
    ReleaseMapLock()
    ProcedureReturn 0
  EndIf

  If CreateOverlayWindow(*em) = 0
    SetLastError("Failed to create overlay window.")
    FreeDIB(*em)
    AcquireMapLock()
    DeleteMapElement(gEmitters(), key)
    ReleaseMapLock()
    ProcedureReturn 0
  EndIf

  If *em\config\burstCount > 0
    Protected i.i
    For i = 1 To *em\config\burstCount
      SpawnParticle(*em)
    Next
    *em\burstDone = 1
  EndIf

  *em\running = 1
  *em\lastTickCount = GetTickCount_()
  *em\timerID = 0   ; no per-emitter timer; using shared timer instead
  StartSharedTimerIfNeeded()

  If g_sharedTimerActive = 0
    SetLastError("Failed to start shared multimedia timer.")
    If *em\hOverlay : DestroyWindow_(*em\hOverlay) : *em\hOverlay = 0 : EndIf
    FreeDIB(*em)
    AcquireMapLock()
    DeleteMapElement(gEmitters(), key)
    ReleaseMapLock()
    ProcedureReturn 0
  EndIf

  SetNBVar("[NeoParticle.LastError]", "")
  ProcedureReturn 1
EndProcedure

; Common prologue: validate rect, get host HWND + size, create map entry,
; apply preset. Returns the emitter pointer on success, 0 on failure
; (with LastError set). 'outW'/'outH' receive host dimensions.
Procedure.i CreateEmitterShell(rectName.s, presetName.s, *outW.Long, *outH.Long)
  Protected key.s = LCase(Trim(rectName))
  If key = ""
    SetLastError("Empty rectangle name.")
    ProcedureReturn 0
  EndIf

  ; Hold the map lock from the cursor-touching Find through element
  ; creation + init; reentrant, so the nested StopEmitter is fine.
  AcquireMapLock()
  If FindMapElement(gEmitters(), key)
    StopEmitter(key)
  EndIf

  Protected hHost.i = GetObjectHandle(rectName)
  If hHost = 0 Or IsWindow_(hHost) = 0
    ReleaseMapLock()
    SetLastError("Rectangle '" + rectName + "' not found.")
    ProcedureReturn 0
  EndIf

  Protected rc.RECT
  GetClientRect_(hHost, @rc)
  Protected w.i = rc\right - rc\left
  Protected h.i = rc\bottom - rc\top
  If w <= 0 Or h <= 0
    ReleaseMapLock()
    SetLastError("Rectangle has zero size.")
    ProcedureReturn 0
  EndIf

  AddMapElement(gEmitters(), key)
  Protected *em.ParticleEmitter = @gEmitters()

  ; DEFENSIVE: zero runtime state fields immediately after AddMapElement.
  ; PB documentation says new map elements are "default value" initialized,
  ; but in practice when a deleted-then-readded slot reuses memory, fields
  ; we don't explicitly assign can retain garbage data. Most dangerous:
  ;   - spawnAccum: if garbage was huge (e.g. 50000), the spawn loop runs
  ;     50000+ iterations per frame, EACH scanning all particle slots.
  ;     That's tens of millions of array reads per frame = severe slowdown.
  ;   - particles[*]\alive: garbage 1's get processed as live particles,
  ;     rendered at garbage positions/colors, looking like junk filling the rect.
  ;   - activeCount: garbage value misleads the render-loop early-exit.
  ; Setting these explicitly here makes us immune to PB's memory reuse
  ; behavior, regardless of what it actually does.
  *em\running = 0
  *em\timerID = 0
  *em\hOverlay = 0
  *em\hDIB = 0
  *em\dibBits = 0
  *em\dibW = 0
  *em\dibH = 0
  *em\refH = 0
  *em\activeCount = 0
  *em\spawnAccum = 0.0
  *em\timeAcc = 0.0
  *em\lastTickCount = 0
  *em\burstDone = 0
  Protected zpi.i
  For zpi = 0 To #MAX_PARTICLES - 1
    *em\particles[zpi]\alive = 0
  Next

  *em\rectName = rectName
  *em\hHost = hHost
  *em\presetName = presetName
  *em\lcPresetName = LCase(presetName)
  *em\lastHostW = w
  *em\lastHostH = h
  *em\topMost = 1
  ApplyPreset(presetName, @*em\config)

  PokeL(*outW, w)
  PokeL(*outH, h)
  ReleaseMapLock()
  ProcedureReturn *em
EndProcedure

; ----- Simple Play: preset + intensity + solid colour override -----
Procedure.i StartEmitter(rectName.s, presetName.s, intensity.i, colorStr.s)
  Protected w.l, h.l
  Protected *em.ParticleEmitter = CreateEmitterShell(rectName, presetName, @w, @h)
  If *em = 0 : ProcedureReturn 0 : EndIf

  ApplyIntensityAndColor(@*em\config, intensity, colorStr)

  ProcedureReturn FinalizeEmitter(*em, w, h)
EndProcedure

; ----- Advanced Play: full control via options string -----
;
;   options is a "key=value;key=value;..." string. Recognised keys:
;     colorStart  R,G,B
;     colorEnd    R,G,B
;     emitArea    X,Y,W,H   (all 0..1)
;     direction   angle,spread (degrees)
;     speed       float multiplier  (1.0 = preset)
;     size        float multiplier
;     life        float multiplier
;     mode        Continuous / Continuous:R / Burst / Burst:N
;     zOrder      Front / Behind
;     opacity     0.0..1.0  (alpha multiplier)
;
;   Unknown keys are ignored. Empty options => act like simple Play with
;   intensity-scaled preset.
Procedure.i StartEmitterAdvanced(rectName.s, presetName.s, intensity.i, options.s)
  Protected w.l, h.l
  Protected *em.ParticleEmitter = CreateEmitterShell(rectName, presetName, @w, @h)
  If *em = 0 : ProcedureReturn 0 : EndIf

  ApplyIntensity(@*em\config, intensity)
  ApplyAdvancedFromOptions(*em, options)

  ProcedureReturn FinalizeEmitter(*em, w, h)
EndProcedure

; ============================================================
;  Live preview helpers (used by Edit_ParticlePlay)
; ============================================================

; Reset the preview emitter for a new preset/intensity/color.
; Reuses the existing DIB.
Procedure ResetPreviewEmitter(*pem.ParticleEmitter, presetName.s, intensity.i, colorStr.s)
  ; Wipe particles
  Protected i.i
  For i = 0 To #MAX_PARTICLES - 1
    *pem\particles[i]\alive = 0
  Next
  *pem\activeCount = 0
  *pem\spawnAccum = 0.0
  *pem\timeAcc = 0.0
  *pem\burstDone = 0
  *pem\presetName = presetName
  *pem\lcPresetName = LCase(presetName)

  ApplyPreset(presetName, @*pem\config)
  ApplyIntensityAndColor(@*pem\config, intensity, colorStr)

  ; Pre-fill so user sees something immediately
  If *pem\config\burstCount > 0
    Protected b.i
    For b = 1 To *pem\config\burstCount
      SpawnParticle(*pem)
    Next
    *pem\burstDone = 1
  Else
    Protected k.i
    For k = 1 To 20
      SpawnParticle(*pem)
    Next
  EndIf
EndProcedure

; Drive one preview frame: update + render + blit DIB to a CanvasGadget.
Procedure StepPreview(*pem.ParticleEmitter, gCanvas.i, *lastTick.Long)
  Protected now.i = GetTickCount_()
  Protected elapsed.i = now - PeekL(*lastTick)
  If elapsed <= 0 : elapsed = #PREVIEW_FPS_MS : EndIf
  If elapsed > 100 : elapsed = 100 : EndIf
  PokeL(*lastTick, now)
  Protected dt.f = elapsed / 1000.0
  *pem\timeAcc + dt

  UpdateParticles(*pem, dt)

  ; Loop burst presets so preview never goes empty
  If *pem\config\burstCount > 0 And *pem\activeCount = 0
    Protected i.i
    For i = 1 To *pem\config\burstCount
      SpawnParticle(*pem)
    Next
  EndIf

  ; RenderParticles bails if running=0
  *pem\running = 1
  RenderParticles(*pem)

  ; Blit DIB to canvas via direct GDI on the canvas window's HDC.
  ; (Avoids PB OutputID() on CanvasOutput, which PB 6.21 refuses to compile here.)
  Protected hCanvasWnd.i = GadgetID(gCanvas)
  If hCanvasWnd = 0 : ProcedureReturn : EndIf
  Protected dstDC.i = GetDC_(hCanvasWnd)
  If dstDC = 0 : ProcedureReturn : EndIf

  ; The DIB is pre-multiplied alpha cleared to (0,0,0,0). Each particle pixel
  ; holds (R*a, G*a, B*a, a). A plain SRCCOPY puts those bytes on the canvas:
  ; transparent areas show as black, particle pixels show as their correctly
  ; composited-on-black colour. No AlphaBlend needed (and AlphaBlend lives in
  ; msimg32.dll which PB doesn't auto-import in shared DLL builds).
  Protected memDC.i = CreateCompatibleDC_(dstDC)
  Protected hOld.i = SelectObject_(memDC, *pem\hDIB)
  BitBlt_(dstDC, 0, 0, *pem\dibW, *pem\dibH, memDC, 0, 0, #SRCCOPY)
  SelectObject_(memDC, hOld)
  DeleteDC_(memDC)
  ReleaseDC_(hCanvasWnd, dstDC)
EndProcedure

; ============================================================
;  Design-time editors
; ============================================================

; Map combobox index <-> preset name (single source of truth for the dialog).
; Order matches AddGadgetItem calls below.
Procedure.s PresetNameByIndex(idx.i)
  Select idx
    Case 0  : ProcedureReturn "Sparkle"
    Case 1  : ProcedureReturn "Fire"
    Case 2  : ProcedureReturn "Rain"
    Case 3  : ProcedureReturn "Snow"
    Case 4  : ProcedureReturn "Confetti"
    Case 5  : ProcedureReturn "MagicDust"
    Case 6  : ProcedureReturn "Explosion"
    Case 7  : ProcedureReturn "LightRays"
    Case 8  : ProcedureReturn "Beams"
    Case 9  : ProcedureReturn "Glow"
    Case 10 : ProcedureReturn "Stars"
    Case 11 : ProcedureReturn "LightBeams"
    Case 12 : ProcedureReturn "GodRays"
    Case 13 : ProcedureReturn "Laser"
    Case 14 : ProcedureReturn "Bubbles"
    Case 15 : ProcedureReturn "PulseWave"
    Case 16 : ProcedureReturn "Aurora"
    Case 17 : ProcedureReturn "Shimmer"
    Case 18 : ProcedureReturn "AuroraBorealis"
  EndSelect
  ProcedureReturn "Sparkle"
EndProcedure

Procedure.i PresetIndexByName(name.s)
  Select LCase(Trim(name))
    Case "sparkle"    : ProcedureReturn 0
    Case "fire"       : ProcedureReturn 1
    Case "rain"       : ProcedureReturn 2
    Case "snow"       : ProcedureReturn 3
    Case "confetti"   : ProcedureReturn 4
    Case "magicdust"  : ProcedureReturn 5
    Case "explosion"  : ProcedureReturn 6
    Case "lightrays"  : ProcedureReturn 7
    Case "beams"      : ProcedureReturn 8
    Case "glow"       : ProcedureReturn 9
    Case "stars"      : ProcedureReturn 10
    Case "lightbeams" : ProcedureReturn 11
    Case "godrays"    : ProcedureReturn 12
    Case "laser"      : ProcedureReturn 13
    Case "bubbles"    : ProcedureReturn 14
    Case "pulsewave"  : ProcedureReturn 15
    Case "aurora"     : ProcedureReturn 16
    Case "shimmer"    : ProcedureReturn 17
    Case "auroraborealis" : ProcedureReturn 18
  EndSelect
  ProcedureReturn 0
EndProcedure

; Returns 1 when the named preset has nonzero twinkleSpeed or pulseSpeed in
; ApplyPreset (i.e. the Twinkle slider has visible effect on it). Used by
; Edit_ParticlePlayAdvanced to grey out the slider for presets that ignore it.
Procedure.i PresetUsesTwinkle(name.s)
  Select LCase(Trim(name))
    Case "stars", "glow", "lightbeams", "godrays", "laser", "aurora", "shimmer"
      ProcedureReturn 1
  EndSelect
  ProcedureReturn 0
EndProcedure

; ------ Editor: ParticlePlay (with live animated preview) ------
Procedure.i Edit_ParticlePlay(*Params.Long)
  Protected hParent.i  = GetActiveWindow_()
  Protected rectStr.s  = ReadParamString(*Params, 0)
  Protected presetStr.s = ReadParamString(*Params, 1)
  Protected intStr.s   = ReadParamString(*Params, 2)
  Protected colStr.s   = ReadParamString(*Params, 3)
  Protected ret.i = 0

  If presetStr = "" : presetStr = "Sparkle" : EndIf
  If intStr = ""    : intStr = "50"         : EndIf

  ; Initial colour values for the dialog
  Protected initR.i = 255, initG.i = 255, initB.i = 255
  Protected initOverride.i = 0
  If colStr <> ""
    Protected pr.l, pg.l, pb.l
    If ParseColorString(colStr, @pr, @pg, @pb)
      initR = pr : initG = pg : initB = pb
      initOverride = 1
    EndIf
  EndIf

  Protected win = OpenWindow(#PB_Any, 0, 0, 720, 380, "NeoParticleAnimator - Play",
                             #PB_Window_SystemMenu | #PB_Window_ScreenCentered, hParent)
  If win = 0 : ProcedureReturn 0 : EndIf

  ; ---- Left form ----
  TextGadget(#PB_Any, 10, 14, 90, 20, "Rectangle:")
  Protected gRect = StringGadget(#PB_Any, 110, 10, 200, 22, rectStr)
  Protected gBtnRect = ButtonGadget(#PB_Any, 315, 10, 75, 22, "Select...")

  TextGadget(#PB_Any, 10, 44, 90, 20, "Preset:")
  Protected gPreset = ComboBoxGadget(#PB_Any, 110, 40, 200, 22)
  AddGadgetItem(gPreset, -1, "Sparkle")
  AddGadgetItem(gPreset, -1, "Fire")
  AddGadgetItem(gPreset, -1, "Rain")
  AddGadgetItem(gPreset, -1, "Snow")
  AddGadgetItem(gPreset, -1, "Confetti")
  AddGadgetItem(gPreset, -1, "MagicDust")
  AddGadgetItem(gPreset, -1, "Explosion")
  AddGadgetItem(gPreset, -1, "LightRays")
  AddGadgetItem(gPreset, -1, "Beams")
  AddGadgetItem(gPreset, -1, "Glow")
  AddGadgetItem(gPreset, -1, "Stars")
  AddGadgetItem(gPreset, -1, "LightBeams")
  AddGadgetItem(gPreset, -1, "GodRays")
  AddGadgetItem(gPreset, -1, "Laser")
  AddGadgetItem(gPreset, -1, "Bubbles")
  AddGadgetItem(gPreset, -1, "PulseWave")
  AddGadgetItem(gPreset, -1, "Aurora")
  AddGadgetItem(gPreset, -1, "Shimmer")
  AddGadgetItem(gPreset, -1, "AuroraBorealis")
  SetGadgetState(gPreset, PresetIndexByName(presetStr))

  TextGadget(#PB_Any, 10, 78, 90, 20, "Intensity:")
  Protected gIntensity = TrackBarGadget(#PB_Any, 110, 74, 200, 24, 0, 100)
  SetGadgetState(gIntensity, ClampI(Val(intStr), 0, 100))
  Protected gIntLabel = TextGadget(#PB_Any, 320, 78, 60, 20, Str(GetGadgetState(gIntensity)) + "%")

  Protected gOverride = CheckBoxGadget(#PB_Any, 10, 112, 130, 22, "Override color")
  If initOverride : SetGadgetState(gOverride, 1) : EndIf

  TextGadget(#PB_Any, 145, 116, 12, 20, "R")
  Protected gR = StringGadget(#PB_Any, 160, 112, 35, 22, Str(initR))
  TextGadget(#PB_Any, 200, 116, 12, 20, "G")
  Protected gG = StringGadget(#PB_Any, 215, 112, 35, 22, Str(initG))
  TextGadget(#PB_Any, 255, 116, 12, 20, "B")
  Protected gB = StringGadget(#PB_Any, 270, 112, 35, 22, Str(initB))
  Protected gBtnPick = ButtonGadget(#PB_Any, 315, 112, 75, 22, "Pick...")

  ; Color preview swatch
  Protected gSwatch = CanvasGadget(#PB_Any, 110, 142, 60, 18)
  If StartDrawing(CanvasOutput(gSwatch))
    Box(0, 0, 60, 18, RGB(initR, initG, initB))
    StopDrawing()
  EndIf
  TextGadget(#PB_Any, 175, 145, 200, 18, "(used when 'Override color' is on)")

  TextGadget(#PB_Any, 10, 180, 380, 100,
             "Intensity 0-100 scales the spawn rate (50 = preset default, " +
             "100 = 2x). Override color replaces both birth and death colors " +
             "with one solid color (alpha still fades naturally). Leave " +
             "override off to use the preset's natural colors. " + #CRLF$ + #CRLF$ +
             "The right pane shows a live preview as you adjust settings.")

  ; ---- Right preview ----
  TextGadget(#PB_Any, 410, 14, 200, 18, "Live Preview:")
  Protected gPreview = CanvasGadget(#PB_Any, 410, 34, #PREVIEW_W, #PREVIEW_H)
  ; Initialise the canvas backing buffer to black so WM_PAINT repaints
  ; (e.g. when the dialog is dragged) don't flash white between frames.
  If StartDrawing(CanvasOutput(gPreview))
    Box(0, 0, #PREVIEW_W, #PREVIEW_H, RGB(0, 0, 0))
    StopDrawing()
  EndIf

  ; ---- Buttons ----
  Protected gOK     = ButtonGadget(#PB_Any, 525, 320, 90, 32, "OK")
  Protected gCancel = ButtonGadget(#PB_Any, 620, 320, 90, 32, "Cancel")

  ; ---- Preview emitter ----
  Static previewEm.ParticleEmitter
  ; Ensure DIB is right size (Static persists across opens; resize if needed)
  EnsureDIB(@previewEm, #PREVIEW_W, #PREVIEW_H)
  ResetPreviewEmitter(@previewEm, presetStr, GetGadgetState(gIntensity), colStr)

  Protected previewLastTick.l = GetTickCount_()
  AddWindowTimer(win, #PREVIEW_TIMER_ID, #PREVIEW_FPS_MS)

  ; ---- Helper: rebuild colour string from gadgets ----
  ; (No closures in PB; we just inline this where needed.)

  Protected done.i = 0
  Repeat
    Protected ev.i = WaitWindowEvent()

    Select ev
      Case #PB_Event_CloseWindow
        done = 1

      Case #PB_Event_Timer
        If EventTimer() = #PREVIEW_TIMER_ID
          StepPreview(@previewEm, gPreview, @previewLastTick)
        EndIf

      Case #PB_Event_Gadget
        Select EventGadget()
          Case gBtnRect
            Protected sel.s = SelectRectangle(GetGadgetText(gRect))
            If sel <> "" : SetGadgetText(gRect, sel) : EndIf

          Case gPreset
            ; Preset changed -> rebuild preview from scratch
            Protected newPreset.s = PresetNameByIndex(GetGadgetState(gPreset))
            Protected curCol.s = ""
            If GetGadgetState(gOverride)
              curCol = Trim(GetGadgetText(gR)) + "," + Trim(GetGadgetText(gG)) + "," + Trim(GetGadgetText(gB))
            EndIf
            ResetPreviewEmitter(@previewEm, newPreset, GetGadgetState(gIntensity), curCol)

          Case gIntensity
            ; Intensity slider moved -> update label and live-rescale spawn rate.
            ; A fresh ApplyPreset+ApplyIntensityAndColor gives a stable baseline
            ; instead of compounding the previous scaling.
            SetGadgetText(gIntLabel, Str(GetGadgetState(gIntensity)) + "%")
            Protected curPreset.s = PresetNameByIndex(GetGadgetState(gPreset))
            Protected curColI.s = ""
            If GetGadgetState(gOverride)
              curColI = Trim(GetGadgetText(gR)) + "," + Trim(GetGadgetText(gG)) + "," + Trim(GetGadgetText(gB))
            EndIf
            ApplyPreset(curPreset, @previewEm\config)
            ApplyIntensityAndColor(@previewEm\config, GetGadgetState(gIntensity), curColI)

          Case gOverride, gR, gG, gB
            ; Update swatch
            Protected swR.i = ClampI(Val(GetGadgetText(gR)), 0, 255)
            Protected swG.i = ClampI(Val(GetGadgetText(gG)), 0, 255)
            Protected swB.i = ClampI(Val(GetGadgetText(gB)), 0, 255)
            If StartDrawing(CanvasOutput(gSwatch))
              Box(0, 0, 60, 18, RGB(swR, swG, swB))
              StopDrawing()
            EndIf
            ; Update preview config (colour change applies to NEW particles)
            Protected cpPreset.s = PresetNameByIndex(GetGadgetState(gPreset))
            Protected cpCol.s = ""
            If GetGadgetState(gOverride)
              cpCol = Str(swR) + "," + Str(swG) + "," + Str(swB)
            EndIf
            ApplyPreset(cpPreset, @previewEm\config)
            ApplyIntensityAndColor(@previewEm\config, GetGadgetState(gIntensity), cpCol)

          Case gBtnPick
            Protected curR.i = ClampI(Val(GetGadgetText(gR)), 0, 255)
            Protected curG.i = ClampI(Val(GetGadgetText(gG)), 0, 255)
            Protected curB.i = ClampI(Val(GetGadgetText(gB)), 0, 255)
            Protected picked.l = ColorRequester(RGBtoCOLORREF(curR, curG, curB))
            If picked <> -1
              SetGadgetText(gR, Str(ExtractRed(picked)))
              SetGadgetText(gG, Str(ExtractGreen(picked)))
              SetGadgetText(gB, Str(ExtractBlue(picked)))
              SetGadgetState(gOverride, 1)
              If StartDrawing(CanvasOutput(gSwatch))
                Box(0, 0, 60, 18, picked)
                StopDrawing()
              EndIf
              ; Apply immediately to preview
              Protected pkPreset.s = PresetNameByIndex(GetGadgetState(gPreset))
              Protected pkCol.s = Str(ExtractRed(picked)) + "," + Str(ExtractGreen(picked)) + "," + Str(ExtractBlue(picked))
              ApplyPreset(pkPreset, @previewEm\config)
              ApplyIntensityAndColor(@previewEm\config, GetGadgetState(gIntensity), pkCol)
            EndIf

          Case gOK
            WriteParamString(*Params, 0, Trim(GetGadgetText(gRect)))
            WriteParamString(*Params, 1, PresetNameByIndex(GetGadgetState(gPreset)))
            WriteParamString(*Params, 2, Str(GetGadgetState(gIntensity)))
            If GetGadgetState(gOverride)
              Protected okR.i = ClampI(Val(GetGadgetText(gR)), 0, 255)
              Protected okG.i = ClampI(Val(GetGadgetText(gG)), 0, 255)
              Protected okB.i = ClampI(Val(GetGadgetText(gB)), 0, 255)
              WriteParamString(*Params, 3, Str(okR) + "," + Str(okG) + "," + Str(okB))
            Else
              WriteParamString(*Params, 3, "")
            EndIf
            ret = 1 : done = 1

          Case gCancel
            done = 1
        EndSelect
    EndSelect
  Until done

  ; Stop preview animation; keep DIB allocated for next open (Static)
  RemoveWindowTimer(win, #PREVIEW_TIMER_ID)
  previewEm\running = 0
  CloseWindow(win)
  ProcedureReturn ret
EndProcedure

; ============================================================
;  Helpers for the PlayAdvanced dialog
;  (string <-> combo index conversions, slider <-> string, etc.)
; ============================================================

; --- Emit Area ---
; Combo order:
;   0 Use Preset  1 Full Area  2 Center Point
;   3 Top Edge    4 Top Half   5 Bottom Edge   6 Bottom Half
;   7 Left Edge   8 Right Edge 9 Left Half     10 Right Half
;   11 (custom)   <- only added if loaded value didn't match any of 1..10
Procedure.s EmitAreaPreset(idx.i)
  Select idx
    Case 1  : ProcedureReturn "0.5,0.5,1.0,1.0"   ; Full Area
    Case 2  : ProcedureReturn "0.5,0.5,0.0,0.0"   ; Center Point
    Case 3  : ProcedureReturn "0.5,0.0,1.0,0.05"  ; Top Edge
    Case 4  : ProcedureReturn "0.5,0.25,1.0,0.5"  ; Top Half
    Case 5  : ProcedureReturn "0.5,1.0,1.0,0.05"  ; Bottom Edge
    Case 6  : ProcedureReturn "0.5,0.75,1.0,0.5"  ; Bottom Half
    Case 7  : ProcedureReturn "0.0,0.5,0.05,1.0"  ; Left Edge
    Case 8  : ProcedureReturn "1.0,0.5,0.05,1.0"  ; Right Edge
    Case 9  : ProcedureReturn "0.25,0.5,0.5,1.0"  ; Left Half
    Case 10 : ProcedureReturn "0.75,0.5,0.5,1.0"  ; Right Half
  EndSelect
  ProcedureReturn ""
EndProcedure

Procedure.i EmitAreaToIndex(s.s)
  If Trim(s) = "" : ProcedureReturn 0 : EndIf
  Protected i.i
  For i = 1 To 10
    If EmitAreaPreset(i) = s : ProcedureReturn i : EndIf
  Next
  ProcedureReturn 11   ; (custom)
EndProcedure

; --- Direction ---
; Combo order (using screen-space angle convention: 0=right, 90=down, 180=left, 270=up):
;   0 Use Preset  1 Up  2 Down  3 Left  4 Right
;   5 Up-Left  6 Up-Right  7 Down-Left  8 Down-Right
;   9 Omnidirectional       10 (custom)
Procedure.f DirectionAngle(idx.i)
  Select idx
    Case 1 : ProcedureReturn 270.0   ; Up
    Case 2 : ProcedureReturn  90.0   ; Down
    Case 3 : ProcedureReturn 180.0   ; Left
    Case 4 : ProcedureReturn   0.0   ; Right
    Case 5 : ProcedureReturn 225.0   ; Up-Left
    Case 6 : ProcedureReturn 315.0   ; Up-Right
    Case 7 : ProcedureReturn 135.0   ; Down-Left
    Case 8 : ProcedureReturn  45.0   ; Down-Right
    Case 9 : ProcedureReturn   0.0   ; Omnidirectional (angle irrelevant)
  EndSelect
  ProcedureReturn 0.0
EndProcedure

; Returns the dirStr index that matches "angle,spread", or 10 (custom) if no match.
Procedure.i DirectionToIndex(s.s, *outSpread.Long)
  PokeL(*outSpread, 60)   ; default if not parsed
  If Trim(s) = "" : ProcedureReturn 0 : EndIf
  Protected aStr.s = Trim(StringField(s, 1, ","))
  Protected sStr.s = Trim(StringField(s, 2, ","))
  If aStr = "" : ProcedureReturn 0 : EndIf
  Protected angle.f = ValF(aStr)
  Protected spread.i = Val(sStr)
  PokeL(*outSpread, ClampI(spread, 0, 360))

  ; Omnidirectional = spread 360 (angle doesn't matter)
  If spread >= 359 : ProcedureReturn 9 : EndIf

  Protected i.i
  For i = 1 To 8
    If Abs(angle - DirectionAngle(i)) < 0.5
      ProcedureReturn i
    EndIf
  Next
  ProcedureReturn 10   ; (custom)
EndProcedure

; --- Mode ---
; 0 Use Preset  1 Continuous  2 Burst:50  3 Burst:100
; 4 Burst:200   5 Burst:500   6 Burst:1000   7 (custom)
Procedure.s ModePreset(idx.i)
  Select idx
    Case 1 : ProcedureReturn "Continuous"
    Case 2 : ProcedureReturn "Burst:50"
    Case 3 : ProcedureReturn "Burst:100"
    Case 4 : ProcedureReturn "Burst:200"
    Case 5 : ProcedureReturn "Burst:500"
    Case 6 : ProcedureReturn "Burst:1000"
  EndSelect
  ProcedureReturn ""
EndProcedure

Procedure.i ModeToIndex(s.s)
  Protected t.s = Trim(s)
  If t = "" : ProcedureReturn 0 : EndIf
  Protected i.i
  For i = 1 To 6
    If LCase(ModePreset(i)) = LCase(t) : ProcedureReturn i : EndIf
  Next
  ProcedureReturn 7   ; (custom)
EndProcedure

; --- Shape combo ---
; 0 Use Preset  1 Circle  2 Rectangle  3 Star  4 Glow
; 5 Ray  6 Beam  7 Laser  8 LightBurst  9 Sprite  10 Ring
Procedure.s ShapeNameByIndex(idx.i)
  Select idx
    Case 1 : ProcedureReturn "Circle"
    Case 2 : ProcedureReturn "Rectangle"
    Case 3 : ProcedureReturn "Star"
    Case 4 : ProcedureReturn "Glow"
    Case 5 : ProcedureReturn "Ray"
    Case 6 : ProcedureReturn "Beam"
    Case 7 : ProcedureReturn "Laser"
    Case 8 : ProcedureReturn "LightBurst"
    Case 9 : ProcedureReturn "Sprite"
    Case 10 : ProcedureReturn "Ring"
  EndSelect
  ProcedureReturn ""
EndProcedure

Procedure.i ShapeToIndex(s.s)
  Select LCase(Trim(s))
    Case ""                  : ProcedureReturn 0
    Case "circle"            : ProcedureReturn 1
    Case "rectangle", "rect" : ProcedureReturn 2
    Case "star"              : ProcedureReturn 3
    Case "glow"              : ProcedureReturn 4
    Case "ray"               : ProcedureReturn 5
    Case "beam"              : ProcedureReturn 6
    Case "laser"             : ProcedureReturn 7
    Case "lightburst"        : ProcedureReturn 8
    Case "sprite"            : ProcedureReturn 9
    Case "ring"              : ProcedureReturn 10
  EndSelect
  ProcedureReturn 0
EndProcedure

; --- Multiplier sliders ---
; New design: each multiplier slider has a companion "Preset" checkbox.
;   Checkbox CHECKED  = use preset value (no override saved; slider greyed)
;   Checkbox UNCHECKED = numeric override; slider goes 0..N where each unit
;                        is 0.1x. So slider=0 is 0.0x (off), slider=10 is
;                        1.0x, slider=50 is 5.0x, slider=100 is 10.0x.
;
; Compared to the old design (slider position 0 meant "use preset"), the
; checkbox layout makes the "I'm overriding this" intent explicit.

; Should the checkbox be checked? Empty string (no key in options) = yes.
Procedure.i MulStrToCheckbox(s.s)
  If Trim(s) = "" : ProcedureReturn 1 : EndIf
  ProcedureReturn 0
EndProcedure

; What slider position should we set, given an option-string value?
; If the option is empty (no override), we still need a slider position --
; pre-set to 1.0x so when the user unchecks the checkbox, they have a
; sensible default value rather than 0.0x (which would freeze particles).
Procedure.i MulStrToSliderNum(s.s)
  If Trim(s) = "" : ProcedureReturn 10 : EndIf   ; 1.0x default when unchecking
  Protected v.f = ValF(s)
  If v < 0.0 : v = 0.0 : EndIf
  ProcedureReturn Round(v * 10.0, #PB_Round_Nearest)
EndProcedure

; Numeric option string from slider position (always non-empty).
Procedure.s MulSliderToValueStr(slider.i)
  Protected f.f = slider / 10.0
  ProcedureReturn StrF(f, 1)
EndProcedure

; Display label for slider position. "off" at 0, "X.Xx" elsewhere.
Procedure.s MulSliderToValueLabel(slider.i)
  If slider = 0 : ProcedureReturn "off" : EndIf
  ProcedureReturn StrF(slider / 10.0, 1) + "x"
EndProcedure

; --- Colour swatch helper ---
Procedure FillSwatch(gCanvas.i, w.i, h.i, hasColor.i, r.i, g.i, b.i)
  If StartDrawing(CanvasOutput(gCanvas))
    If hasColor
      Box(0, 0, w, h, RGB(r, g, b))
    Else
      ; Diagonal stripe pattern to indicate "no override"
      Box(0, 0, w, h, RGB(220, 220, 220))
      Protected lineColor.l = RGB(170, 170, 170)
      Protected x.i
      For x = -h To w Step 6
        LineXY(x, 0, x + h, h, lineColor)
      Next
    EndIf
    StopDrawing()
  EndIf
EndProcedure

; ============================================================
;  Editor: ParticlePlayAdvanced (slider/combo-driven, live preview)
; ============================================================
Procedure.i Edit_ParticlePlayAdvanced(*Params.Long)
  Protected hParent.i  = GetActiveWindow_()

  ; ---- Read 4 wire params, then parse options string ----
  Protected rectStr.s    = ReadParamString(*Params, 0)
  Protected presetStr.s  = ReadParamString(*Params, 1)
  Protected intStr.s     = ReadParamString(*Params, 2)
  Protected optStr.s     = ReadParamString(*Params, 3)
  Protected ret.i = 0

  If presetStr = "" : presetStr = "Sparkle" : EndIf
  If intStr = ""    : intStr = "50"         : EndIf

  Protected colStartStr.s = ParseOption(optStr, "colorStart")
  Protected colEndStr.s   = ParseOption(optStr, "colorEnd")
  Protected emitAreaStr.s = ParseOption(optStr, "emitArea")
  Protected dirStr.s      = ParseOption(optStr, "direction")
  Protected speedMulStr.s = ParseOption(optStr, "speed")
  Protected sizeMulStr.s  = ParseOption(optStr, "size")
  Protected lifeMulStr.s  = ParseOption(optStr, "life")
  Protected modeStr.s     = ParseOption(optStr, "mode")
  Protected zOrderStr.s   = ParseOption(optStr, "zOrder")
  Protected opacityStr.s  = ParseOption(optStr, "opacity")
  Protected densityStr.s  = ParseOption(optStr, "density")
  Protected twinkleStr.s  = ParseOption(optStr, "twinkle")
  Protected shapeStr.s    = ParseOption(optStr, "shape")
  Protected spritePathStr.s = ParseOption(optStr, "sprite")
  Protected featherStr.s  = ParseOption(optStr, "feather")
  Protected taperStr.s    = ParseOption(optStr, "taper")

  ; ---- Combo index mappings ----
  ; Emit Area: the dropdown lists 10 named presets (indices 1..10). A value
  ; that matches none of them is a CUSTOM 4-tuple (e.g. "0.5,0.5,0.45,1.0").
  ; Previously such values were silently coerced to "Use Preset" (index 0),
  ; which DISCARDED the user's custom emit area on the round-trip: opening the
  ; dialog and clicking OK without changing anything rewrote the action with no
  ; emitArea, so the emitter reverted to the preset's own (often very different)
  ; spawn region -- visibly changing the effect. We now preserve custom values
  ; exactly the way Direction and Mode already do: keep the string in
  ; emitAreaCustom, show it as a "(custom: ...)" combo item, and re-emit it
  ; verbatim on OK. EmitAreaToIndex returns 11 for a custom value.
  Protected emitAreaIdx.i = EmitAreaToIndex(emitAreaStr)
  Protected emitAreaCustom.s = ""
  If emitAreaIdx = 11 : emitAreaCustom = emitAreaStr : EndIf

  Protected dirSpread.l = 60
  Protected dirIdx.i = DirectionToIndex(dirStr, @dirSpread)
  Protected dirCustom.s = ""
  If dirIdx = 10 : dirCustom = dirStr : EndIf

  Protected modeIdx.i = ModeToIndex(modeStr)
  Protected modeCustom.s = ""
  If modeIdx = 7 : modeCustom = modeStr : EndIf

  ; ---- Local state for colour overrides ----
  Protected curColStart.s = Trim(colStartStr)
  Protected curColEnd.s   = Trim(colEndStr)

  Protected win = OpenWindow(#PB_Any, 0, 0, 720, 708, "NeoParticleAnimator - PlayAdvanced",
                             #PB_Window_SystemMenu | #PB_Window_ScreenCentered, hParent)
  If win = 0 : ProcedureReturn 0 : EndIf

  ; ---- Form column (x = 10 .. 395) ----
  Protected fy.i = 12
  Protected lblW.i = 90
  Protected ctlX.i = 105
  Protected ctlW.i = 220

  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Rectangle:")
  Protected gRect = StringGadget(#PB_Any, ctlX, fy, 200, 22, rectStr)
  Protected gBtnRect = ButtonGadget(#PB_Any, 310, fy, 75, 22, "Select...")
  fy + 32

  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Preset:")
  ; Combo confined to the left control zone (x=10..405) -- the preview
  ; canvas occupies x=415..705. Custom preset names with long names get
  ; truncated visually but the full name is still selectable from dropdown.
  Protected gPreset = ComboBoxGadget(#PB_Any, ctlX, fy, 295, 22)
  AddGadgetItem(gPreset, -1, "Sparkle")    : AddGadgetItem(gPreset, -1, "Fire")
  AddGadgetItem(gPreset, -1, "Rain")       : AddGadgetItem(gPreset, -1, "Snow")
  AddGadgetItem(gPreset, -1, "Confetti")   : AddGadgetItem(gPreset, -1, "MagicDust")
  AddGadgetItem(gPreset, -1, "Explosion")  : AddGadgetItem(gPreset, -1, "LightRays")
  AddGadgetItem(gPreset, -1, "Beams")      : AddGadgetItem(gPreset, -1, "Glow")
  AddGadgetItem(gPreset, -1, "Stars")      : AddGadgetItem(gPreset, -1, "LightBeams")
  AddGadgetItem(gPreset, -1, "GodRays")    : AddGadgetItem(gPreset, -1, "Laser")
  AddGadgetItem(gPreset, -1, "Bubbles")    : AddGadgetItem(gPreset, -1, "PulseWave")
  AddGadgetItem(gPreset, -1, "Aurora")     : AddGadgetItem(gPreset, -1, "Shimmer")
  AddGadgetItem(gPreset, -1, "AuroraBorealis")
  ; ROLLED BACK: custom preset appending disabled. See IsCustomPreset for context.
  SetGadgetState(gPreset, PresetIndexByName(presetStr))
  fy + 32

  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Intensity:")
  Protected gIntensity = TrackBarGadget(#PB_Any, ctlX, fy - 2, ctlW - 40, 24, 0, 100)
  SetGadgetState(gIntensity, ClampI(Val(intStr), 0, 100))
  Protected gIntLabel = TextGadget(#PB_Any, ctlX + ctlW - 35, fy + 2, 50, 20, Str(GetGadgetState(gIntensity)) + "%")
  fy + 32

  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Color Start:")
  Protected gSwatchStart   = CanvasGadget(#PB_Any, ctlX, fy + 1, 50, 22)
  Protected gBtnPickStart  = ButtonGadget(#PB_Any, ctlX + 55, fy, 60, 22, "Pick...")
  Protected gBtnClearStart = ButtonGadget(#PB_Any, ctlX + 120, fy, 60, 22, "Clear")
  Protected gLblStart      = TextGadget(#PB_Any, ctlX + 185, fy + 2, 145, 20, "")
  fy + 32

  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Color End:")
  Protected gSwatchEnd   = CanvasGadget(#PB_Any, ctlX, fy + 1, 50, 22)
  Protected gBtnPickEnd  = ButtonGadget(#PB_Any, ctlX + 55, fy, 60, 22, "Pick...")
  Protected gBtnClearEnd = ButtonGadget(#PB_Any, ctlX + 120, fy, 60, 22, "Clear")
  Protected gLblEnd      = TextGadget(#PB_Any, ctlX + 185, fy + 2, 145, 20, "")
  fy + 32

  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Emit Area:")
  Protected gEmitArea = ComboBoxGadget(#PB_Any, ctlX, fy, ctlW, 22)
  AddGadgetItem(gEmitArea, -1, "Use Preset")
  AddGadgetItem(gEmitArea, -1, "Full Area")
  AddGadgetItem(gEmitArea, -1, "Center Point")
  AddGadgetItem(gEmitArea, -1, "Top Edge")
  AddGadgetItem(gEmitArea, -1, "Top Half")
  AddGadgetItem(gEmitArea, -1, "Bottom Edge")
  AddGadgetItem(gEmitArea, -1, "Bottom Half")
  AddGadgetItem(gEmitArea, -1, "Left Edge")
  AddGadgetItem(gEmitArea, -1, "Right Edge")
  AddGadgetItem(gEmitArea, -1, "Left Half")
  AddGadgetItem(gEmitArea, -1, "Right Half")
  ; Index 11: a custom 4-tuple carried over from the action string that matches
  ; no named preset. Shown so the user can see (and keep) their exact value.
  If emitAreaCustom <> "" : AddGadgetItem(gEmitArea, -1, "(custom: " + emitAreaCustom + ")") : EndIf
  SetGadgetState(gEmitArea, emitAreaIdx)
  fy + 32

  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Direction:")
  Protected gDir = ComboBoxGadget(#PB_Any, ctlX, fy, ctlW, 22)
  AddGadgetItem(gDir, -1, "Use Preset")
  AddGadgetItem(gDir, -1, "Up")           : AddGadgetItem(gDir, -1, "Down")
  AddGadgetItem(gDir, -1, "Left")         : AddGadgetItem(gDir, -1, "Right")
  AddGadgetItem(gDir, -1, "Up-Left")      : AddGadgetItem(gDir, -1, "Up-Right")
  AddGadgetItem(gDir, -1, "Down-Left")    : AddGadgetItem(gDir, -1, "Down-Right")
  AddGadgetItem(gDir, -1, "Omnidirectional")
  If dirCustom <> "" : AddGadgetItem(gDir, -1, "(custom: " + dirCustom + ")") : EndIf
  SetGadgetState(gDir, dirIdx)
  fy + 32

  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Spread:")
  Protected gSpread = TrackBarGadget(#PB_Any, ctlX, fy - 2, ctlW - 40, 24, 0, 360)
  SetGadgetState(gSpread, dirSpread)
  Protected gSpreadLabel = TextGadget(#PB_Any, ctlX + ctlW - 35, fy + 2, 50, 20, Str(dirSpread) + Chr(176))
  fy + 32

  ; --- Multiplier slider rows ---
  ; Each row has: [Label] [Slider, narrower] [Value] [Preset checkbox].
  ; Checkbox controls whether the slider value is used (unchecked) or the
  ; preset's own value is used (checked, slider greyed).
  ;
  ; Slider widths are reduced (was ctlW-60, now ctlW-90) to make room for
  ; the checkbox on the right edge of the row.

  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Speed:")
  Protected gSpeed = TrackBarGadget(#PB_Any, ctlX, fy - 2, ctlW - 90, 24, 0, 50)
  SetGadgetState(gSpeed, MulStrToSliderNum(speedMulStr))
  Protected gSpeedLabel = TextGadget(#PB_Any, ctlX + ctlW - 85, fy + 2, 50, 20, MulSliderToValueLabel(GetGadgetState(gSpeed)))
  Protected gSpeedPreset = CheckBoxGadget(#PB_Any, ctlX + ctlW - 30, fy + 2, 80, 20, "Preset")
  SetGadgetState(gSpeedPreset, MulStrToCheckbox(speedMulStr))
  If GetGadgetState(gSpeedPreset) : DisableGadget(gSpeed, 1) : SetGadgetText(gSpeedLabel, "preset") : EndIf
  fy + 32

  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Size:")
  Protected gSize = TrackBarGadget(#PB_Any, ctlX, fy - 2, ctlW - 90, 24, 0, 50)
  SetGadgetState(gSize, MulStrToSliderNum(sizeMulStr))
  Protected gSizeLabel = TextGadget(#PB_Any, ctlX + ctlW - 85, fy + 2, 50, 20, MulSliderToValueLabel(GetGadgetState(gSize)))
  Protected gSizePreset = CheckBoxGadget(#PB_Any, ctlX + ctlW - 30, fy + 2, 80, 20, "Preset")
  SetGadgetState(gSizePreset, MulStrToCheckbox(sizeMulStr))
  If GetGadgetState(gSizePreset) : DisableGadget(gSize, 1) : SetGadgetText(gSizeLabel, "preset") : EndIf
  fy + 32

  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Lifetime:")
  Protected gLife = TrackBarGadget(#PB_Any, ctlX, fy - 2, ctlW - 90, 24, 0, 50)
  SetGadgetState(gLife, MulStrToSliderNum(lifeMulStr))
  Protected gLifeLabel = TextGadget(#PB_Any, ctlX + ctlW - 85, fy + 2, 50, 20, MulSliderToValueLabel(GetGadgetState(gLife)))
  Protected gLifePreset = CheckBoxGadget(#PB_Any, ctlX + ctlW - 30, fy + 2, 80, 20, "Preset")
  SetGadgetState(gLifePreset, MulStrToCheckbox(lifeMulStr))
  If GetGadgetState(gLifePreset) : DisableGadget(gLife, 1) : SetGadgetText(gLifeLabel, "preset") : EndIf
  fy + 32

  ; Density: spawnRate + burstCount multiplier. Slider 0..100 -> 0.0x..10.0x.
  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Density:")
  Protected gDensity = TrackBarGadget(#PB_Any, ctlX, fy - 2, ctlW - 90, 24, 0, 100)
  SetGadgetState(gDensity, MulStrToSliderNum(densityStr))
  Protected gDensityLabel = TextGadget(#PB_Any, ctlX + ctlW - 85, fy + 2, 50, 20, MulSliderToValueLabel(GetGadgetState(gDensity)))
  Protected gDensityPreset = CheckBoxGadget(#PB_Any, ctlX + ctlW - 30, fy + 2, 80, 20, "Preset")
  SetGadgetState(gDensityPreset, MulStrToCheckbox(densityStr))
  If GetGadgetState(gDensityPreset) : DisableGadget(gDensity, 1) : SetGadgetText(gDensityLabel, "preset") : EndIf
  fy + 32

  ; Twinkle: scales twinkleSpeed (Stars, Glow) and pulseSpeed (beam presets).
  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Twinkle:")
  Protected gTwinkle = TrackBarGadget(#PB_Any, ctlX, fy - 2, ctlW - 90, 24, 0, 50)
  SetGadgetState(gTwinkle, MulStrToSliderNum(twinkleStr))
  Protected gTwinkleLabel = TextGadget(#PB_Any, ctlX + ctlW - 85, fy + 2, 50, 20, MulSliderToValueLabel(GetGadgetState(gTwinkle)))
  Protected gTwinklePreset = CheckBoxGadget(#PB_Any, ctlX + ctlW - 30, fy + 2, 80, 20, "Preset")
  SetGadgetState(gTwinklePreset, MulStrToCheckbox(twinkleStr))
  If GetGadgetState(gTwinklePreset) : DisableGadget(gTwinkle, 1) : SetGadgetText(gTwinkleLabel, "preset") : EndIf
  fy + 32

  ; Opacity (NEW): 0..100% where 100% = preset alpha unchanged
  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Opacity:")
  Protected gOpacity = TrackBarGadget(#PB_Any, ctlX, fy - 2, ctlW - 40, 24, 0, 100)
  Protected initOpacity.i = 100
  If opacityStr <> ""
    initOpacity = ClampI(Round(ValF(opacityStr) * 100.0, #PB_Round_Nearest), 0, 100)
  EndIf
  SetGadgetState(gOpacity, initOpacity)
  Protected gOpacityLabel = TextGadget(#PB_Any, ctlX + ctlW - 35, fy + 2, 50, 20, Str(initOpacity) + "%")
  fy + 32

  ; Feather (NEW): edge fade-out so particles near the rect boundary fade
  ; smoothly instead of hitting the hard edge. Slider 0..50 maps to 0..0.5
  ; (fraction of rect on each side). 0 = no feathering, 0.15 = mild,
  ; 0.30 = strong. Essential when emitting across full rect area with a
  ; visible background where the rect outline would otherwise show.
  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Feather:")
  Protected gFeather = TrackBarGadget(#PB_Any, ctlX, fy - 2, ctlW - 90, 24, 0, 50)
  Protected initFeatherSlider.i = 0
  Protected featherIsPreset.i = 1
  If featherStr <> ""
    featherIsPreset = 0
    initFeatherSlider = ClampI(Round(ValF(featherStr) * 100.0, #PB_Round_Nearest), 0, 50)
  EndIf
  SetGadgetState(gFeather, initFeatherSlider)
  Protected gFeatherLabel = TextGadget(#PB_Any, ctlX + ctlW - 85, fy + 2, 50, 20,
                                       StrF(initFeatherSlider / 100.0, 2))
  Protected gFeatherPreset = CheckBoxGadget(#PB_Any, ctlX + ctlW - 30, fy + 2, 80, 20, "Preset")
  SetGadgetState(gFeatherPreset, featherIsPreset)
  If featherIsPreset
    DisableGadget(gFeather, 1)
    SetGadgetText(gFeatherLabel, "preset")
  EndIf
  fy + 32

  ; Taper (NEW): trapezoid spawn region. Slider 0..100 maps to 0.00..1.00
  ; where 1.00 = no taper (rectangular), 0.30 = top is 30% as wide as
  ; bottom (gentle fan), 0.00 = single point at top (triangle). Useful
  ; for moonlight reflections, fountain spray, anywhere a rectangle
  ; spawn region looks unnaturally boxy.
  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Taper:")
  Protected gTaper = TrackBarGadget(#PB_Any, ctlX, fy - 2, ctlW - 90, 24, 0, 100)
  Protected initTaperSlider.i = 0   ; default: no taper (off)
  Protected taperIsPreset.i = 1
  If taperStr <> ""
    taperIsPreset = 0
    initTaperSlider = ClampI(Round(ValF(taperStr) * 100.0, #PB_Round_Nearest), 0, 100)
  EndIf
  SetGadgetState(gTaper, initTaperSlider)
  Protected gTaperLabel = TextGadget(#PB_Any, ctlX + ctlW - 85, fy + 2, 50, 20,
                                     StrF(initTaperSlider / 100.0, 2))
  Protected gTaperPreset = CheckBoxGadget(#PB_Any, ctlX + ctlW - 30, fy + 2, 80, 20, "Preset")
  SetGadgetState(gTaperPreset, taperIsPreset)
  If taperIsPreset
    DisableGadget(gTaper, 1)
    SetGadgetText(gTaperLabel, "preset")
  EndIf
  fy + 32

  ; Taper Edge: which side of the rect is the narrow end. Top is default
  ; (moonlight reflection style). Bottom inverts for fountain spray.
  ; Left/Right for horizontal fans.
  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Taper Edge:")
  Protected gTaperEdge = ComboBoxGadget(#PB_Any, ctlX, fy, ctlW, 22)
  AddGadgetItem(gTaperEdge, -1, "Top (narrow at top)")
  AddGadgetItem(gTaperEdge, -1, "Bottom (narrow at bottom)")
  AddGadgetItem(gTaperEdge, -1, "Left (narrow at left)")
  AddGadgetItem(gTaperEdge, -1, "Right (narrow at right)")
  Protected initTaperEdgeIdx.i = 0
  Protected taperEdgeStrInit.s = LCase(Trim(ParseOption(optStr, "taperEdge")))
  Select taperEdgeStrInit
    Case "bottom", "1" : initTaperEdgeIdx = 1
    Case "left",   "2" : initTaperEdgeIdx = 2
    Case "right",  "3" : initTaperEdgeIdx = 3
  EndSelect
  SetGadgetState(gTaperEdge, initTaperEdgeIdx)
  fy + 32

  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Mode:")
  Protected gMode = ComboBoxGadget(#PB_Any, ctlX, fy, ctlW, 22)
  AddGadgetItem(gMode, -1, "Use Preset")
  AddGadgetItem(gMode, -1, "Continuous")
  AddGadgetItem(gMode, -1, "Burst (50)")
  AddGadgetItem(gMode, -1, "Burst (100)")
  AddGadgetItem(gMode, -1, "Burst (200)")
  AddGadgetItem(gMode, -1, "Burst (500)")
  AddGadgetItem(gMode, -1, "Burst (1000)")
  If modeCustom <> "" : AddGadgetItem(gMode, -1, "(custom: " + modeCustom + ")") : EndIf
  SetGadgetState(gMode, modeIdx)
  fy + 32

  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Shape:")
  Protected gShape = ComboBoxGadget(#PB_Any, ctlX, fy, ctlW, 22)
  AddGadgetItem(gShape, -1, "Use Preset")
  AddGadgetItem(gShape, -1, "Circle")
  AddGadgetItem(gShape, -1, "Rectangle")
  AddGadgetItem(gShape, -1, "Star")
  AddGadgetItem(gShape, -1, "Glow")
  AddGadgetItem(gShape, -1, "Ray")
  AddGadgetItem(gShape, -1, "Beam")
  AddGadgetItem(gShape, -1, "Laser")
  AddGadgetItem(gShape, -1, "LightBurst")
  AddGadgetItem(gShape, -1, "Sprite")
  AddGadgetItem(gShape, -1, "Ring")
  SetGadgetState(gShape, ShapeToIndex(shapeStr))
  fy + 32

  ; Sprite file: only meaningful when Shape = Sprite. Visible always so
  ; users can pre-load a sprite before switching shape, or change it
  ; without re-opening the dialog.
  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Sprite File:")
  Protected gSpriteFile = StringGadget(#PB_Any, ctlX, fy, ctlW, 22, spritePathStr)
  Protected gBtnSprite  = ButtonGadget(#PB_Any, ctlX + ctlW + 5, fy, 70, 22, "Browse...")
  fy + 32

  TextGadget(#PB_Any, 10, fy + 2, lblW, 20, "Z-Order:")
  Protected gZOrder = ComboBoxGadget(#PB_Any, ctlX, fy, 130, 22)
  AddGadgetItem(gZOrder, -1, "Front (default)")
  AddGadgetItem(gZOrder, -1, "Behind")
  If LCase(Trim(zOrderStr)) = "behind"
    SetGadgetState(gZOrder, 1)
  Else
    SetGadgetState(gZOrder, 0)
  EndIf
  fy + 36

  ; ---- Right preview ----
  TextGadget(#PB_Any, 415, 14, 200, 18, "Live Preview:")
  Protected gPreview = CanvasGadget(#PB_Any, 415, 34, #PREVIEW_W, #PREVIEW_H)
  If StartDrawing(CanvasOutput(gPreview))
    Box(0, 0, #PREVIEW_W, #PREVIEW_H, RGB(0, 0, 0))
    StopDrawing()
  EndIf
  TextGadget(#PB_Any, 415, 264, 290, 95,
             "All fields except Z-Order update the preview live. " +
             "Sliders at the leftmost position (labelled 'use preset') " +
             "leave that dimension untouched. Opacity 100% = preset's " +
             "natural alpha; lower values dim the whole effect.")

  ; ---- Buttons ----
  Protected gReset  = ButtonGadget(#PB_Any, 425, 658, 90, 32, "Reset")
  Protected gOK     = ButtonGadget(#PB_Any, 525, 658, 90, 32, "OK")
  Protected gCancel = ButtonGadget(#PB_Any, 620, 658, 90, 32, "Cancel")

  ; ---- Initial swatches + labels ----
  Protected sR.l, sG.l, sB.l
  If ParseColorString(curColStart, @sR, @sG, @sB)
    FillSwatch(gSwatchStart, 50, 22, 1, sR, sG, sB)
    SetGadgetText(gLblStart, "RGB " + Str(sR) + "," + Str(sG) + "," + Str(sB))
  Else
    FillSwatch(gSwatchStart, 50, 22, 0, 0, 0, 0)
    SetGadgetText(gLblStart, "(preset default)")
  EndIf
  If ParseColorString(curColEnd, @sR, @sG, @sB)
    FillSwatch(gSwatchEnd, 50, 22, 1, sR, sG, sB)
    SetGadgetText(gLblEnd, "RGB " + Str(sR) + "," + Str(sG) + "," + Str(sB))
  Else
    FillSwatch(gSwatchEnd, 50, 22, 0, 0, 0, 0)
    SetGadgetText(gLblEnd, "(preset default)")
  EndIf

  ; ---- Spread enable/disable based on initial direction ----
  If dirIdx = 0 Or dirIdx = 9
    DisableGadget(gSpread, 1)
  Else
    DisableGadget(gSpread, 0)
  EndIf

  ; ---- Twinkle enable/disable based on initial preset ----
  ; Only Stars/Glow/LightBeams/GodRays/Laser have nonzero twinkleSpeed or
  ; pulseSpeed in their preset, so the slider does nothing for the others.
  ; When preset doesn't use Twinkle, disable both the slider AND the
  ; "Preset" checkbox (greyed-out row signals "doesn't apply here").
  If PresetUsesTwinkle(presetStr)
    DisableGadget(gTwinklePreset, 0)
    ; Slider state respects the checkbox
    If GetGadgetState(gTwinklePreset)
      DisableGadget(gTwinkle, 1)
    Else
      DisableGadget(gTwinkle, 0)
    EndIf
  Else
    DisableGadget(gTwinklePreset, 1)
    DisableGadget(gTwinkle, 1)
  EndIf

  ; ---- Preview emitter ----
  Static previewEm.ParticleEmitter
  EnsureDIB(@previewEm, #PREVIEW_W, #PREVIEW_H)

  Protected wi.i
  For wi = 0 To #MAX_PARTICLES - 1
    previewEm\particles[wi]\alive = 0
  Next
  previewEm\activeCount = 0
  previewEm\spawnAccum = 0.0
  previewEm\timeAcc = 0.0
  previewEm\burstDone = 0
  previewEm\presetName = PresetNameByIndex(GetGadgetState(gPreset))
  previewEm\lcPresetName = LCase(previewEm\presetName)

  Protected previewLastTick.l = GetTickCount_()
  AddWindowTimer(win, #PREVIEW_TIMER_ID, #PREVIEW_FPS_MS)

  Protected done.i = 0
  Protected hardReset.i = 1
  Protected refresh.i = 1

  Repeat
    Protected ev.i = WaitWindowEvent()

    Select ev
      Case #PB_Event_CloseWindow
        done = 1

      Case #PB_Event_Timer
        If EventTimer() = #PREVIEW_TIMER_ID
          StepPreview(@previewEm, gPreview, @previewLastTick)
        EndIf

      Case #PB_Event_Gadget
        Select EventGadget()
          Case gBtnRect
            Protected sel.s = SelectRectangle(GetGadgetText(gRect))
            If sel <> "" : SetGadgetText(gRect, sel) : EndIf

          Case gPreset, gMode
            If EventGadget() = gPreset
              ; ---- Reset all overrides to "use preset" state ----
              ; Switching presets snaps every override back to its no-op
              ; position so the new preset starts from its own clean baseline.
              ; Z-Order and Rectangle are intentionally NOT reset (layering
              ; and target are independent of preset choice).
              SetGadgetState(gIntensity, 50)
              SetGadgetText(gIntLabel, "50%")

              curColStart = ""
              curColEnd = ""
              FillSwatch(gSwatchStart, 50, 22, 0, 0, 0, 0)
              FillSwatch(gSwatchEnd,   50, 22, 0, 0, 0, 0)
              SetGadgetText(gLblStart, "(preset default)")
              SetGadgetText(gLblEnd,   "(preset default)")

              SetGadgetState(gEmitArea, 0)
              SetGadgetState(gDir,      0)
              SetGadgetState(gSpread,   60)
              SetGadgetText(gSpreadLabel, Str(60) + Chr(176))
              DisableGadget(gSpread, 1)   ; Direction = Use Preset disables spread

              ; Reset multiplier sliders to 1.0x default and check all
              ; "Preset" checkboxes (= use preset, slider greyed out).
              SetGadgetState(gSpeed,   10)
              SetGadgetState(gSize,    10)
              SetGadgetState(gLife,    10)
              SetGadgetState(gDensity, 10)
              SetGadgetState(gTwinkle, 10)
              SetGadgetState(gSpeedPreset,   1) : DisableGadget(gSpeed, 1)   : SetGadgetText(gSpeedLabel,   "preset")
              SetGadgetState(gSizePreset,    1) : DisableGadget(gSize, 1)    : SetGadgetText(gSizeLabel,    "preset")
              SetGadgetState(gLifePreset,    1) : DisableGadget(gLife, 1)    : SetGadgetText(gLifeLabel,    "preset")
              SetGadgetState(gDensityPreset, 1) : DisableGadget(gDensity, 1) : SetGadgetText(gDensityLabel, "preset")
              SetGadgetState(gTwinklePreset, 1) : DisableGadget(gTwinkle, 1) : SetGadgetText(gTwinkleLabel, "preset")
              SetGadgetState(gFeather, 0)
              SetGadgetState(gFeatherPreset, 1) : DisableGadget(gFeather, 1) : SetGadgetText(gFeatherLabel, "preset")
              SetGadgetState(gTaper, 0)
              SetGadgetState(gTaperPreset, 1) : DisableGadget(gTaper, 1) : SetGadgetText(gTaperLabel, "preset")
              SetGadgetState(gTaperEdge, 0)

              SetGadgetState(gOpacity, 100)
              SetGadgetText(gOpacityLabel, "100%")

              SetGadgetState(gMode, 0)
              SetGadgetState(gShape, 0)
              SetGadgetText(gSpriteFile, "")

              ; Proactively release any sprite handle from the previous
              ; preset / session. The Static previewEm persists across
              ; preset switches and dialog opens, and a stale GDI+ handle
              ; left in *em\hSpriteImage can crash the next LoadEmitterSprite
              ; (which calls GdipDisposeImage on whatever's there).
              ReleaseEmitterSprite(@previewEm)

              ; Re-evaluate Twinkle enabled state for the new preset.
              ; Whole row goes grey for presets that don't use modulation.
              If PresetUsesTwinkle(PresetNameByIndex(GetGadgetState(gPreset)))
                DisableGadget(gTwinklePreset, 0)
                ; Checkbox is now checked (we just reset it), so slider
                ; stays disabled until user unchecks
              Else
                DisableGadget(gTwinklePreset, 1)
                ; gTwinkle is already disabled from the reset above
              EndIf
            EndIf
            hardReset = 1 : refresh = 1

          Case gIntensity
            SetGadgetText(gIntLabel, Str(GetGadgetState(gIntensity)) + "%")
            refresh = 1

          Case gEmitArea
            refresh = 1

          Case gDir
            Protected dIdx.i = GetGadgetState(gDir)
            If dIdx = 0 Or dIdx = 9
              DisableGadget(gSpread, 1)
            Else
              DisableGadget(gSpread, 0)
            EndIf
            refresh = 1

          Case gSpread
            SetGadgetText(gSpreadLabel, Str(GetGadgetState(gSpread)) + Chr(176))
            refresh = 1

          Case gSpeed
            SetGadgetText(gSpeedLabel, MulSliderToValueLabel(GetGadgetState(gSpeed)))
            refresh = 1

          Case gSize
            SetGadgetText(gSizeLabel, MulSliderToValueLabel(GetGadgetState(gSize)))
            refresh = 1

          Case gLife
            SetGadgetText(gLifeLabel, MulSliderToValueLabel(GetGadgetState(gLife)))
            refresh = 1

          Case gOpacity
            SetGadgetText(gOpacityLabel, Str(GetGadgetState(gOpacity)) + "%")
            refresh = 1

          Case gFeather
            ; Slider 0..50 -> 0.0..0.50 (fraction of rect). Display 2 decimals.
            SetGadgetText(gFeatherLabel, StrF(GetGadgetState(gFeather) / 100.0, 2))
            refresh = 1

          Case gFeatherPreset
            If GetGadgetState(gFeatherPreset)
              DisableGadget(gFeather, 1) : SetGadgetText(gFeatherLabel, "preset")
            Else
              DisableGadget(gFeather, 0)
              SetGadgetText(gFeatherLabel, StrF(GetGadgetState(gFeather) / 100.0, 2))
            EndIf
            refresh = 1

          Case gTaper
            ; Slider 0..100 -> 0.00..1.00 (taper fraction).
            SetGadgetText(gTaperLabel, StrF(GetGadgetState(gTaper) / 100.0, 2))
            refresh = 1

          Case gTaperPreset
            If GetGadgetState(gTaperPreset)
              DisableGadget(gTaper, 1) : SetGadgetText(gTaperLabel, "preset")
            Else
              DisableGadget(gTaper, 0)
              SetGadgetText(gTaperLabel, StrF(GetGadgetState(gTaper) / 100.0, 2))
            EndIf
            refresh = 1

          Case gTaperEdge
            refresh = 1

          Case gDensity
            SetGadgetText(gDensityLabel, MulSliderToValueLabel(GetGadgetState(gDensity)))
            refresh = 1

          Case gTwinkle
            SetGadgetText(gTwinkleLabel, MulSliderToValueLabel(GetGadgetState(gTwinkle)))
            refresh = 1

          Case gSpeedPreset
            If GetGadgetState(gSpeedPreset)
              DisableGadget(gSpeed, 1) : SetGadgetText(gSpeedLabel, "preset")
            Else
              DisableGadget(gSpeed, 0) : SetGadgetText(gSpeedLabel, MulSliderToValueLabel(GetGadgetState(gSpeed)))
            EndIf
            refresh = 1

          Case gSizePreset
            If GetGadgetState(gSizePreset)
              DisableGadget(gSize, 1) : SetGadgetText(gSizeLabel, "preset")
            Else
              DisableGadget(gSize, 0) : SetGadgetText(gSizeLabel, MulSliderToValueLabel(GetGadgetState(gSize)))
            EndIf
            refresh = 1

          Case gLifePreset
            If GetGadgetState(gLifePreset)
              DisableGadget(gLife, 1) : SetGadgetText(gLifeLabel, "preset")
            Else
              DisableGadget(gLife, 0) : SetGadgetText(gLifeLabel, MulSliderToValueLabel(GetGadgetState(gLife)))
            EndIf
            refresh = 1

          Case gDensityPreset
            If GetGadgetState(gDensityPreset)
              DisableGadget(gDensity, 1) : SetGadgetText(gDensityLabel, "preset")
            Else
              DisableGadget(gDensity, 0) : SetGadgetText(gDensityLabel, MulSliderToValueLabel(GetGadgetState(gDensity)))
            EndIf
            refresh = 1

          Case gTwinklePreset
            If GetGadgetState(gTwinklePreset)
              DisableGadget(gTwinkle, 1) : SetGadgetText(gTwinkleLabel, "preset")
            Else
              DisableGadget(gTwinkle, 0) : SetGadgetText(gTwinkleLabel, MulSliderToValueLabel(GetGadgetState(gTwinkle)))
            EndIf
            refresh = 1

          Case gBtnPickStart
            Protected curRs.l = 255, curGs.l = 255, curBs.l = 255
            ParseColorString(curColStart, @curRs, @curGs, @curBs)
            Protected pickedS.l = ColorRequester(RGBtoCOLORREF(curRs, curGs, curBs))
            If pickedS <> -1
              Protected psR.i = ExtractRed(pickedS)
              Protected psG.i = ExtractGreen(pickedS)
              Protected psB.i = ExtractBlue(pickedS)
              curColStart = Str(psR) + "," + Str(psG) + "," + Str(psB)
              FillSwatch(gSwatchStart, 50, 22, 1, psR, psG, psB)
              SetGadgetText(gLblStart, "RGB " + curColStart)
              refresh = 1
            EndIf

          Case gBtnClearStart
            curColStart = ""
            FillSwatch(gSwatchStart, 50, 22, 0, 0, 0, 0)
            SetGadgetText(gLblStart, "(preset default)")
            refresh = 1

          Case gBtnPickEnd
            Protected curRe.l = 255, curGe.l = 255, curBe.l = 255
            ParseColorString(curColEnd, @curRe, @curGe, @curBe)
            Protected pickedE.l = ColorRequester(RGBtoCOLORREF(curRe, curGe, curBe))
            If pickedE <> -1
              Protected peR.i = ExtractRed(pickedE)
              Protected peG.i = ExtractGreen(pickedE)
              Protected peB.i = ExtractBlue(pickedE)
              curColEnd = Str(peR) + "," + Str(peG) + "," + Str(peB)
              FillSwatch(gSwatchEnd, 50, 22, 1, peR, peG, peB)
              SetGadgetText(gLblEnd, "RGB " + curColEnd)
              refresh = 1
            EndIf

          Case gBtnClearEnd
            curColEnd = ""
            FillSwatch(gSwatchEnd, 50, 22, 0, 0, 0, 0)
            SetGadgetText(gLblEnd, "(preset default)")
            refresh = 1

          Case gZOrder
            ; No preview impact

          Case gShape
            ; If user just switched TO Sprite, auto-prompt for file if none picked yet
            If GetGadgetState(gShape) = 9 And Trim(GetGadgetText(gSpriteFile)) = ""
              Protected initSel.s = OpenFileRequester("Select sprite image", "",
                                                      "Image Files (*.png; *.jpg; *.bmp; *.gif)|*.png;*.jpg;*.bmp;*.gif|All Files (*.*)|*.*", 0)
              If initSel <> ""
                SetGadgetText(gSpriteFile, initSel)
                hardReset = 1
              EndIf
            EndIf
            ; Switching shape to/from Sprite always benefits from a clean slate
            ; (see gBtnSprite handler comment).
            If GetGadgetState(gShape) = 9
              hardReset = 1
            EndIf
            refresh = 1

          Case gBtnSprite
            Protected curSpr.s = Trim(GetGadgetText(gSpriteFile))
            Protected sprSel.s = OpenFileRequester("Select sprite image", curSpr,
                                                    "Image Files (*.png; *.jpg; *.bmp; *.gif)|*.png;*.jpg;*.bmp;*.gif|All Files (*.*)|*.*", 0)
            If sprSel <> ""
              SetGadgetText(gSpriteFile, sprSel)
              ; Auto-switch Shape combo to Sprite if user just picked one
              If GetGadgetState(gShape) <> 9
                SetGadgetState(gShape, 9)
              EndIf
              ; Hard-reset to clear in-flight particles. Otherwise, when
              ; switching from a heavy preset (Fire, Explosion) to sprite,
              ; 60+ existing particles all start rendering as sprites at
              ; once -- frame time spikes, timer events back up, the UI
              ; freezes, and VNW eventually kills the plugin. Clearing
              ; particles lets the sprite emitter build up gradually.
              hardReset = 1
              refresh = 1
            EndIf

          Case gSpriteFile
            ; User edited path manually
            refresh = 1

          Case gOK
            ; ---- Build options string from current state ----
            Protected okEmitIdx.i = GetGadgetState(gEmitArea)
            Protected okEmitStr.s
            If okEmitIdx = 0
              okEmitStr = ""
            ElseIf okEmitIdx = 11
              ; Custom 4-tuple carried over from the original action string.
              ; Emit it verbatim so OK never silently reverts a user's custom
              ; emit area to the preset default (the round-trip data-loss bug).
              okEmitStr = emitAreaCustom
            Else
              okEmitStr = EmitAreaPreset(okEmitIdx)
            EndIf

            Protected okDirIdx.i = GetGadgetState(gDir)
            Protected okSpreadVal.i = GetGadgetState(gSpread)
            Protected okDirStr.s
            If okDirIdx = 0
              okDirStr = ""
            ElseIf okDirIdx = 9
              okDirStr = "0,360"
            ElseIf okDirIdx = 10
              okDirStr = dirCustom
            Else
              okDirStr = StrF(DirectionAngle(okDirIdx), 0) + "," + Str(okSpreadVal)
            EndIf

            Protected okModeIdx.i = GetGadgetState(gMode)
            Protected okModeStr.s
            If okModeIdx = 0
              okModeStr = ""
            ElseIf okModeIdx = 7
              okModeStr = modeCustom
            Else
              okModeStr = ModePreset(okModeIdx)
            EndIf

            Protected okOpacity.i = GetGadgetState(gOpacity)
            Protected okOpacityStr.s = ""
            If okOpacity < 100
              okOpacityStr = StrF(okOpacity / 100.0, 2)
            EndIf

            Protected okZOrderStr.s = ""
            If GetGadgetState(gZOrder) = 1 : okZOrderStr = "Behind" : EndIf

            ; Assemble
            Protected outOpts.s = ""
            outOpts = AppendKV(outOpts, "colorStart", curColStart)
            outOpts = AppendKV(outOpts, "colorEnd",   curColEnd)
            outOpts = AppendKV(outOpts, "emitArea",   okEmitStr)
            outOpts = AppendKV(outOpts, "direction",  okDirStr)
            ; Multiplier overrides: empty string when "Preset" checkbox is
            ; checked, numeric value otherwise. AppendKV skips empty values.
            If GetGadgetState(gSpeedPreset)   = 0 : outOpts = AppendKV(outOpts, "speed",   MulSliderToValueStr(GetGadgetState(gSpeed)))   : EndIf
            If GetGadgetState(gSizePreset)    = 0 : outOpts = AppendKV(outOpts, "size",    MulSliderToValueStr(GetGadgetState(gSize)))    : EndIf
            If GetGadgetState(gLifePreset)    = 0 : outOpts = AppendKV(outOpts, "life",    MulSliderToValueStr(GetGadgetState(gLife)))    : EndIf
            If GetGadgetState(gDensityPreset) = 0 : outOpts = AppendKV(outOpts, "density", MulSliderToValueStr(GetGadgetState(gDensity))) : EndIf
            If GetGadgetState(gTwinklePreset) = 0 : outOpts = AppendKV(outOpts, "twinkle", MulSliderToValueStr(GetGadgetState(gTwinkle))) : EndIf
            ; Feather: only emit when "Preset" checkbox is unchecked.
            If GetGadgetState(gFeatherPreset) = 0
              outOpts = AppendKV(outOpts, "feather", StrF(GetGadgetState(gFeather) / 100.0, 2))
            EndIf
            ; Taper: only emit when "Preset" checkbox is unchecked.
            If GetGadgetState(gTaperPreset) = 0
              outOpts = AppendKV(outOpts, "taper", StrF(GetGadgetState(gTaper) / 100.0, 2))
              ; Only emit taperEdge if it's not the default (Top).
              Select GetGadgetState(gTaperEdge)
                Case 1 : outOpts = AppendKV(outOpts, "taperEdge", "Bottom")
                Case 2 : outOpts = AppendKV(outOpts, "taperEdge", "Left")
                Case 3 : outOpts = AppendKV(outOpts, "taperEdge", "Right")
              EndSelect
            EndIf
            outOpts = AppendKV(outOpts, "shape",      ShapeNameByIndex(GetGadgetState(gShape)))
            outOpts = AppendKV(outOpts, "sprite",     Trim(GetGadgetText(gSpriteFile)))
            outOpts = AppendKV(outOpts, "mode",       okModeStr)
            outOpts = AppendKV(outOpts, "zOrder",     okZOrderStr)
            outOpts = AppendKV(outOpts, "opacity",    okOpacityStr)

            WriteParamString(*Params, 0, Trim(GetGadgetText(gRect)))
            WriteParamString(*Params, 1, PresetNameByIndex(GetGadgetState(gPreset)))
            WriteParamString(*Params, 2, Str(GetGadgetState(gIntensity)))
            WriteParamString(*Params, 3, outOpts)
            ret = 1 : done = 1

          Case gCancel
            done = 1

          ; ROLLED BACK: Save Preset / Reset Preset button handlers removed
          ; while custom presets are disabled. See IsCustomPreset for context.

          Case gReset
            ; Panic-button: full clean-slate of the dialog state without
            ; closing it. Wipes the running preview, releases sprite,
            ; and resets every override control to "use preset". Keeps
            ; Preset, Rectangle, and Z-Order (the authoring-intent knobs).
            Protected wri.i
            For wri = 0 To #MAX_PARTICLES - 1
              previewEm\particles[wri]\alive = 0
            Next
            previewEm\activeCount = 0
            previewEm\spawnAccum = 0.0
            previewEm\timeAcc = 0.0
            previewEm\burstDone = 0
            ReleaseEmitterSprite(@previewEm)

            ; Reset all override controls (matches the preset-change reset)
            SetGadgetState(gIntensity, 50)
            SetGadgetText(gIntLabel, "50%")

            curColStart = ""
            curColEnd = ""
            FillSwatch(gSwatchStart, 50, 22, 0, 0, 0, 0)
            FillSwatch(gSwatchEnd,   50, 22, 0, 0, 0, 0)
            SetGadgetText(gLblStart, "(preset default)")
            SetGadgetText(gLblEnd,   "(preset default)")

            SetGadgetState(gEmitArea, 0)
            SetGadgetState(gDir,      0)
            SetGadgetState(gSpread,   60)
            SetGadgetText(gSpreadLabel, Str(60) + Chr(176))
            DisableGadget(gSpread, 1)

            SetGadgetState(gSpeed,   10)
            SetGadgetState(gSize,    10)
            SetGadgetState(gLife,    10)
            SetGadgetState(gDensity, 10)
            SetGadgetState(gTwinkle, 10)
            SetGadgetState(gSpeedPreset,   1) : DisableGadget(gSpeed, 1)   : SetGadgetText(gSpeedLabel,   "preset")
            SetGadgetState(gSizePreset,    1) : DisableGadget(gSize, 1)    : SetGadgetText(gSizeLabel,    "preset")
            SetGadgetState(gLifePreset,    1) : DisableGadget(gLife, 1)    : SetGadgetText(gLifeLabel,    "preset")
            SetGadgetState(gDensityPreset, 1) : DisableGadget(gDensity, 1) : SetGadgetText(gDensityLabel, "preset")
            SetGadgetState(gTwinklePreset, 1) : DisableGadget(gTwinkle, 1) : SetGadgetText(gTwinkleLabel, "preset")
            SetGadgetState(gFeather, 0)
            SetGadgetState(gFeatherPreset, 1) : DisableGadget(gFeather, 1) : SetGadgetText(gFeatherLabel, "preset")
            SetGadgetState(gTaper, 0)
            SetGadgetState(gTaperPreset, 1) : DisableGadget(gTaper, 1) : SetGadgetText(gTaperLabel, "preset")
            SetGadgetState(gTaperEdge, 0)

            SetGadgetState(gOpacity, 100)
            SetGadgetText(gOpacityLabel, "100%")

            SetGadgetState(gMode, 0)
            SetGadgetState(gShape, 0)
            SetGadgetText(gSpriteFile, "")

            ; Re-evaluate Twinkle disabled state for current preset
            If PresetUsesTwinkle(PresetNameByIndex(GetGadgetState(gPreset)))
              DisableGadget(gTwinklePreset, 0)
              ; Checkbox just got reset to checked, so slider stays disabled
            Else
              DisableGadget(gTwinklePreset, 1)
            EndIf

            refresh = 1
        EndSelect
    EndSelect

    ; ---- Apply pending dialog -> preview rebuild ----
    If refresh
      refresh = 0
      Protected pvPreset.s = PresetNameByIndex(GetGadgetState(gPreset))

      ; Build current options string for preview
      Protected pvEmitIdx.i = GetGadgetState(gEmitArea)
      Protected pvEmitStr.s
      If pvEmitIdx = 0      : pvEmitStr = ""
      ElseIf pvEmitIdx = 11 : pvEmitStr = emitAreaCustom   ; custom 4-tuple, preserved
      Else                  : pvEmitStr = EmitAreaPreset(pvEmitIdx)
      EndIf

      Protected pvDirIdx.i = GetGadgetState(gDir)
      Protected pvSpreadVal.i = GetGadgetState(gSpread)
      Protected pvDirStr.s
      If pvDirIdx = 0      : pvDirStr = ""
      ElseIf pvDirIdx = 9  : pvDirStr = "0,360"
      ElseIf pvDirIdx = 10 : pvDirStr = dirCustom
      Else                  : pvDirStr = StrF(DirectionAngle(pvDirIdx), 0) + "," + Str(pvSpreadVal)
      EndIf

      Protected pvModeIdx.i = GetGadgetState(gMode)
      Protected pvModeStr.s
      If pvModeIdx = 0     : pvModeStr = ""
      ElseIf pvModeIdx = 7 : pvModeStr = modeCustom
      Else                  : pvModeStr = ModePreset(pvModeIdx)
      EndIf

      Protected pvOpacity.i = GetGadgetState(gOpacity)
      Protected pvOpacityStr.s = ""
      If pvOpacity < 100 : pvOpacityStr = StrF(pvOpacity / 100.0, 2) : EndIf

      Protected pvOpts.s = ""
      pvOpts = AppendKV(pvOpts, "colorStart", curColStart)
      pvOpts = AppendKV(pvOpts, "colorEnd",   curColEnd)
      pvOpts = AppendKV(pvOpts, "emitArea",   pvEmitStr)
      pvOpts = AppendKV(pvOpts, "direction",  pvDirStr)
      If GetGadgetState(gSpeedPreset)   = 0 : pvOpts = AppendKV(pvOpts, "speed",   MulSliderToValueStr(GetGadgetState(gSpeed)))   : EndIf
      If GetGadgetState(gSizePreset)    = 0 : pvOpts = AppendKV(pvOpts, "size",    MulSliderToValueStr(GetGadgetState(gSize)))    : EndIf
      If GetGadgetState(gLifePreset)    = 0 : pvOpts = AppendKV(pvOpts, "life",    MulSliderToValueStr(GetGadgetState(gLife)))    : EndIf
      If GetGadgetState(gDensityPreset) = 0 : pvOpts = AppendKV(pvOpts, "density", MulSliderToValueStr(GetGadgetState(gDensity))) : EndIf
      If GetGadgetState(gTwinklePreset) = 0 : pvOpts = AppendKV(pvOpts, "twinkle", MulSliderToValueStr(GetGadgetState(gTwinkle))) : EndIf
      If GetGadgetState(gFeatherPreset) = 0
        pvOpts = AppendKV(pvOpts, "feather", StrF(GetGadgetState(gFeather) / 100.0, 2))
      EndIf
      If GetGadgetState(gTaperPreset) = 0
        pvOpts = AppendKV(pvOpts, "taper", StrF(GetGadgetState(gTaper) / 100.0, 2))
        Select GetGadgetState(gTaperEdge)
          Case 1 : pvOpts = AppendKV(pvOpts, "taperEdge", "Bottom")
          Case 2 : pvOpts = AppendKV(pvOpts, "taperEdge", "Left")
          Case 3 : pvOpts = AppendKV(pvOpts, "taperEdge", "Right")
        EndSelect
      EndIf
      pvOpts = AppendKV(pvOpts, "shape",      ShapeNameByIndex(GetGadgetState(gShape)))
      pvOpts = AppendKV(pvOpts, "sprite",     Trim(GetGadgetText(gSpriteFile)))
      pvOpts = AppendKV(pvOpts, "mode",       pvModeStr)
      ; zOrder doesn't affect preview, so we skip it
      pvOpts = AppendKV(pvOpts, "opacity",    pvOpacityStr)

      If hardReset
        hardReset = 0
        Protected wi2.i
        For wi2 = 0 To #MAX_PARTICLES - 1
          previewEm\particles[wi2]\alive = 0
        Next
        previewEm\activeCount = 0
        previewEm\spawnAccum = 0.0
        previewEm\timeAcc = 0.0
        previewEm\burstDone = 0
        previewEm\presetName = pvPreset
        previewEm\lcPresetName = LCase(pvPreset)
      EndIf

      ApplyPreset(pvPreset, @previewEm\config)
      ApplyIntensity(@previewEm\config, GetGadgetState(gIntensity))
      ApplyAdvancedFromOptions(@previewEm, pvOpts)

      If previewEm\activeCount = 0 And previewEm\config\burstCount > 0
        Protected rb.i
        For rb = 1 To previewEm\config\burstCount
          SpawnParticle(@previewEm)
        Next
      EndIf
    EndIf
  Until done

  RemoveWindowTimer(win, #PREVIEW_TIMER_ID)
  previewEm\running = 0
  ReleaseEmitterSprite(@previewEm)
  CloseWindow(win)
  ProcedureReturn ret
EndProcedure


; ------ Editor: ParticleStop ------
Procedure.i Edit_ParticleStop(*Params.Long)
  Protected hParent.i = GetActiveWindow_()
  Protected rectStr.s = ReadParamString(*Params, 0)
  Protected ret.i = 0

  Protected win = OpenWindow(#PB_Any, 0, 0, 380, 130, "NeoParticleAnimator - Stop",
                             #PB_Window_SystemMenu | #PB_Window_ScreenCentered, hParent)
  If win = 0 : ProcedureReturn 0 : EndIf

  TextGadget(#PB_Any, 10, 12, 90, 20, "Rectangle:")
  Protected gRect = StringGadget(#PB_Any, 110, 10, 170, 22, rectStr)
  Protected gBtnRect = ButtonGadget(#PB_Any, 285, 10, 80, 22, "Select...")

  TextGadget(#PB_Any, 10, 42, 360, 32,
             "Leave Rectangle empty to stop ALL active emitters.")

  Protected gOK     = ButtonGadget(#PB_Any, 185, 88, 85, 30, "OK")
  Protected gCancel = ButtonGadget(#PB_Any, 280, 88, 85, 30, "Cancel")

  Protected done.i = 0
  Repeat
    Protected ev.i = WaitWindowEvent()
    If ev = #PB_Event_CloseWindow : done = 1 : EndIf
    If ev = #PB_Event_Gadget
      Select EventGadget()
        Case gBtnRect
          Protected sel.s = SelectRectangle(GetGadgetText(gRect))
          If sel <> "" : SetGadgetText(gRect, sel) : EndIf
        Case gOK
          WriteParamString(*Params, 0, Trim(GetGadgetText(gRect)))
          ret = 1 : done = 1
        Case gCancel
          done = 1
      EndSelect
    EndIf
  Until done
  CloseWindow(win)
  ProcedureReturn ret
EndProcedure

; ============================================================
;  VisualNEO DLL exports
; ============================================================

ProcedureDLL _nbInitPlugIn(hWnd.i, *pTitle.Long, *pAuthor.Long, *pHint.Long)
  g_nbWinHandle = hWnd

  *gPluginTitle  = MakeNBString("NeoParticleAnimator")
  *gPluginAuthor = MakeNBString("Anthony LaTorre")
  *gPluginHint   = MakeNBString("Real-time particle engine. 19 presets, full configuration, live preview.")

  SetOutPtr_NoFree(*pTitle,  *gPluginTitle)
  SetOutPtr_NoFree(*pAuthor, *gPluginAuthor)
  SetOutPtr_NoFree(*pHint,   *gPluginHint)
EndProcedure

ProcedureDLL _nbRegisterPlugIn(AddAction.i, AddFile.i, GetVar.i, SetVar.i)
  g_nbAddAction = AddAction
  g_nbAddFile   = AddFile
  g_nbGetVar    = GetVar
  g_nbSetVar    = SetVar

  If g_nbAddAction
    Static p.TActionParams

    ; Action 1: Play (4 params - rect, preset, intensity, color)
    *gAct1_Name = MakeNBString("NeoParticle_Play")
    *gAct1_Hint = MakeNBString("Play a particle effect on a Rectangle. Pick preset, intensity (0-100), and optional R,G,B color.")
    FillMemory(@p, SizeOf(TActionParams), 0)
    p\p[0] = 1 : p\p[1] = 1 : p\p[2] = 1 : p\p[3] = 1
    CallFunctionFast(g_nbAddAction, #Action_ParticlePlay, *gAct1_Name, *gAct1_Hint, @p, 0, 4)

    ; Action 2: Stop (1 param - rect; empty stops all)
    *gAct2_Name = MakeNBString("NeoParticle_Stop")
    *gAct2_Hint = MakeNBString("Stop a particle effect. Leave Rectangle empty to stop all emitters.")
    FillMemory(@p, SizeOf(TActionParams), 0)
    p\p[0] = 1
    CallFunctionFast(g_nbAddAction, #Action_ParticleStop, *gAct2_Name, *gAct2_Hint, @p, 0, 1)

    ; Action 3: PlayAdvanced (4 params - rect, preset, intensity, options key=value;...)
    *gAct3_Name = MakeNBString("NeoParticle_PlayAdvanced")
    *gAct3_Hint = MakeNBString("Play with full control. 4th param is an options string: key=value;key=value;...")
    FillMemory(@p, SizeOf(TActionParams), 0)
    p\p[0] = 1 : p\p[1] = 1 : p\p[2] = 1 : p\p[3] = 1
    CallFunctionFast(g_nbAddAction, #Action_ParticlePlayAdvanced, *gAct3_Name, *gAct3_Hint, @p, 0, 4)

    ; Action 4: StopAll (0 params - emergency shutdown of every emitter)
    *gAct4_Name = MakeNBString("NeoParticle_StopAll")
    *gAct4_Hint = MakeNBString("Hard-stop every active particle emitter. Kills timers, destroys overlays, pumps the message queue. Use in cleanup/exit handlers.")
    FillMemory(@p, SizeOf(TActionParams), 0)
    CallFunctionFast(g_nbAddAction, #Action_ParticleStopAll, *gAct4_Name, *gAct4_Hint, @p, 0, 0)
  EndIf
EndProcedure

ProcedureDLL.i _nbEditAction(ID.i, *Params.Long)
  Select ID
    Case #Action_ParticlePlay         : ProcedureReturn Edit_ParticlePlay(*Params)
    Case #Action_ParticleStop         : ProcedureReturn Edit_ParticleStop(*Params)
    Case #Action_ParticlePlayAdvanced : ProcedureReturn Edit_ParticlePlayAdvanced(*Params)
    Case #Action_ParticleStopAll      : ProcedureReturn 1  ; no params to edit
  EndSelect
  ProcedureReturn 0
EndProcedure

ProcedureDLL.i _nbExecAction(ID.i, *Params.Long)
  Select ID

    Case #Action_ParticlePlay
      If g_acceptingActions = 0 : ProcedureReturn 0 : EndIf   ; shutting down
      Protected rectName.s   = ReadParamString(*Params, 0)
      Protected presetName.s = ReadParamString(*Params, 1)
      Protected intStr.s     = ReadParamString(*Params, 2)
      Protected colStr.s     = ReadParamString(*Params, 3)

      If presetName = "" : presetName = "Sparkle" : EndIf
      Protected intensity.i = 50
      If intStr <> "" : intensity = ClampI(Val(intStr), 0, 100) : EndIf

      ProcedureReturn StartEmitter(rectName, presetName, intensity, colStr)

    Case #Action_ParticleStop
      Protected stopRect.s = ReadParamString(*Params, 0)
      Protected stopKey.s = LCase(Trim(stopRect))
      If stopKey = ""
        StopAllEmitters()
      Else
        StopEmitter(stopKey)
      EndIf
      ProcedureReturn 1

    Case #Action_ParticlePlayAdvanced
      If g_acceptingActions = 0 : ProcedureReturn 0 : EndIf   ; shutting down
      Protected aRect.s    = ReadParamString(*Params, 0)
      Protected aPreset.s  = ReadParamString(*Params, 1)
      Protected aIntStr.s  = ReadParamString(*Params, 2)
      Protected aOpts.s    = ReadParamString(*Params, 3)

      If aPreset = "" : aPreset = "Sparkle" : EndIf
      Protected aIntensity.i = 50
      If aIntStr <> "" : aIntensity = ClampI(Val(aIntStr), 0, 100) : EndIf

      ProcedureReturn StartEmitterAdvanced(aRect, aPreset, aIntensity, aOpts)

    Case #Action_ParticleStopAll
      StopAllEmitters()
      ProcedureReturn 1

  EndSelect
  ProcedureReturn 0
EndProcedure

ProcedureDLL _nbMessage(MsgCode.i, Reserved.i)
  Select MsgCode
    Case #NBMSG_ENTER_RUNMODE
      g_acceptingActions = 1
    Case #NBMSG_EXIT_RUNMODE
      g_acceptingActions = 0   ; refuse new Play actions from here on
      StopAllEmitters()

    Case #NBMSG_MOVESIZE
      ; Lock: this block resizes DIBs (EnsureDIB frees + reallocates) on
      ; the MAIN thread; the tick renders into those DIBs on the TIMER
      ; thread.  Unlocked overlap = render into freed memory = the heap
      ; corruption behind the ntdll access violation at exit.
      AcquireMapLock()
      ForEach gEmitters()
        If gEmitters()\hOverlay And gEmitters()\hHost
          If IsWindow_(gEmitters()\hHost)
            Protected rc.RECT
            GetClientRect_(gEmitters()\hHost, @rc)
            Protected curW.i = rc\right - rc\left
            Protected curH.i = rc\bottom - rc\top
            If curW <> gEmitters()\lastHostW Or curH <> gEmitters()\lastHostH
              gEmitters()\lastHostW = curW
              gEmitters()\lastHostH = curH
              EnsureDIB(@gEmitters(), curW, curH)
            EndIf
            RepositionOverlay(@gEmitters())
          EndIf
        EndIf
      Next
      ReleaseMapLock()

    Case #NBMSG_MINIMIZE
      AcquireMapLock()
      ForEach gEmitters()
        If gEmitters()\hOverlay And IsWindow_(gEmitters()\hOverlay)
          ShowWindow_(gEmitters()\hOverlay, #SW_HIDE)
        EndIf
      Next
      ReleaseMapLock()

    Case #NBMSG_RESTORE
      AcquireMapLock()
      ; If the title-bar auto-stop owns visibility right now (the user
      ; restored via a caption button, so the cursor is almost certainly
      ; still in the band), leave the overlays hidden -- the tick will
      ; re-show them the moment the cursor leaves the band.
      If g_hoverStopped = 0
        ForEach gEmitters()
          If gEmitters()\hOverlay And IsWindow_(gEmitters()\hOverlay)
            ShowWindow_(gEmitters()\hOverlay, #SW_SHOWNOACTIVATE)
            RepositionOverlay(@gEmitters())
          EndIf
        Next
      EndIf
      ReleaseMapLock()

    Case #NBMSG_PAGECHANGE
      StopAllEmitters()
  EndSelect
EndProcedure

ProcedureDLL _nbRegisterInterfaceAccess(*Proc)
  g_nbInterface = *Proc
EndProcedure

ProcedureDLL _nbRegisterScriptProcessor(*Proc)
  g_nbPlayAction = *Proc
EndProcedure

ProcedureDLL _nbAbout()
  MessageBox_(GetActiveWindow_(),
              "NeoParticleAnimator v2.0 (Simplified API)" + #CRLF$ +
              "Real-time procedural particle engine for VisualNEO Win." + #CRLF$ + #CRLF$ +
              "Four actions:" + #CRLF$ +
              "  Play          - rect, preset, intensity, color" + #CRLF$ +
              "  PlayAdvanced  - full control via options string" + #CRLF$ +
              "  Stop          - rect ('' = stop all)" + #CRLF$ +
              "  StopAll       - hard-stop every emitter (cleanup)" + #CRLF$ + #CRLF$ +
              "19 presets: Sparkle, Fire, Rain, Snow, Confetti, MagicDust," + #CRLF$ +
              "Explosion, LightRays, Beams, Glow, Stars, LightBeams, GodRays," + #CRLF$ +
              "Laser, Bubbles, PulseWave, Aurora, Shimmer, AuroraBorealis." + #CRLF$ + #CRLF$ +
              "PlayAdvanced supports custom sprite images (PNG, JPG, BMP, GIF)" + #CRLF$ +
              "for clouds, smoke, debris, and any custom particle texture." + #CRLF$ + #CRLF$ +
              "Author: Anthony LaTorre",
              "About NeoParticleAnimator",
              #MB_OK | #MB_ICONINFORMATION)
EndProcedure

ProcedureDLL AttachProcess(instance)
  g_hInstance = instance
  InitializeCriticalSection_(@g_mapCS)
  ; (Deliberately never DeleteCriticalSection'd: doing so in DllMain at
  ; process exit is unsafe and unnecessary -- the OS reclaims it.)
EndProcedure

ProcedureDLL DetachProcess(instance)
  ; PROCESS-DETACH SAFE teardown (see g_inDetach).  GdiplusShutdown is
  ; deliberately NOT called here -- calling it from DllMain is
  ; documented-unsafe and was an access-violation-at-exit source; the
  ; OS reclaims the GDI+ token at process exit.
  g_inDetach = 1
  StopAllEmitters()
  g_gdiplusStarted = 0

  ; NB metadata strings (plug-in title/author/hint, action names/hints)
  ; are deliberately NOT GlobalFree'd here.  Ownership of these buffers
  ; passed to VisualNEO at registration (_nbInitPlugIn out-params,
  ; nbAddAction name/hint pointers); a Delphi-built host may dispose
  ; them during ITS shutdown, before plug-ins unload -- freeing them
  ; again here is a double-free that surfaces as an intermittent
  ; access violation in ntdll.dll at process exit (RtlFreeHeap on an
  ; already-freed block).  At process exit the OS reclaims the heap
  ; anyway, so not freeing costs nothing.
EndProcedure

; IDE Options = PureBasic 6.21 (Windows - x86)
; ExecutableFormat = Shared dll
; EnableThread
; EnableXP
; DPIAware
; Executable = NeoParticleAnimator.dll
; CompileSourceDirectory
; Compiler = PureBasic 6.21 (Windows - x86)
