# Phase C.1 PES particle pseudocode Retail sources: - `docs/research/named-retail/acclient_2013_pseudo_c.txt` - `ParticleEmitterInfo::{GetRandom*,InitEnd,ShouldEmitParticle,UnPack}` at `0x005170d0..0x005179f0` - `ParticleManager::{CreateParticleEmitter,DestroyParticleEmitter,StopParticleEmitter}` at `0x0051b6c0..0x0051b7a0` - `Particle::{Update,Init}` and `ParticleEmitter::{EmitParticle,UpdateParticles}` at `0x0051b863..0x0051d400` - `PhysicsScript::{UnPack}` at `0x005218b0` - `CallPESHook::Execute`, `CreateParticleHook::Execute`, `DestroyParticleHook::Execute`, `StopParticleHook::Execute` at `0x00529eb0..0x0052a070` - `GameSky::{Draw,CreateDeletePhysicsObjects}` at `0x00506ff0..0x005075d0` - `docs/research/named-retail/acclient.h` - `EmitterType`, `ParticleType` - `ParticleEmitterInfo`, `Particle`, `ParticleEmitter` - `CreateParticleHook`, `CreateBlockingParticleHook`, `DestroyParticleHook`, `StopParticleHook`, `CallPESHook` - `CelestialPosition` with `pes_id` - Cross-checks: - `references/ACViewer/ACViewer/Physics/Particles/*` - `references/ACE/Source/ACE.DatLoader/Entity/ParticleEmitterInfo.cs` - `references/WorldBuilder/Chorizite.OpenGLSDLBackend/Lib/ParticleBatcher.cs` ## ParticleEmitterInfo ```text UnPack(reader): read id/header read unknown read emitter_type read particle_type read gfxobj_id read hw_gfxobj_id read birthrate read max_particles read initial_particles read total_particles read total_seconds read lifespan read lifespan_rand read offset_dir, min_offset, max_offset read A, min_a, max_a read B, min_b, max_b read C, min_c, max_c read start_scale, final_scale, scale_rand read start_trans, final_trans, trans_rand read is_parent_local InitEnd(): sorting_sphere.center = (0, 0, 0) sorting_sphere.radius = max(max_offset, max_a * lifespan) RandomScale(base): value = base + RollDice(-1, 1) * scale_rand return clamp(value, 0.1, 10.0) RandomTrans(base): value = base + RollDice(-1, 1) * trans_rand return clamp(value, 0.0, 1.0) RandomLifespan(): value = lifespan + RollDice(-1, 1) * lifespan_rand return max(value, 0.0) RandomVector(dir, min, max): return dir * Random(min, max) RandomOffset(): v = random vector in [-1, 1]^3 v = v - project(v, offset_dir) if length(v) is near zero: v = perpendicular fallback v = normalize(v) return v * Random(min_offset, max_offset) ShouldEmitParticle(emitter): if total_particles != 0 and emitter.total_emitted >= total_particles: return false if emitter.num_particles >= max_particles: return false if emitter_type == BirthratePerSec: return Timer.cur_time - emitter.last_emit_time > birthrate if emitter_type == BirthratePerMeter: delta = emitter.last_emit_offset - emitter.current_parent_offset return dot(delta, delta) > birthrate * birthrate return false ``` Notes: - Retail stores `birthrate` as seconds between emissions for `BirthratePerSec`, not particles per second. - Retail clamps start/final scale to `[0.1, 10]` and translucency to `[0, 1]`. - The named decomp shows final scale/trans add their own base values. ACE/ACViewer have a few copy-paste mistakes in these helpers; the decomp wins. ## ParticleManager and emitter lifetime ```text CreateParticleEmitter(parent, emitter_info_id, part_index, offset, requested_id): if requested_id != 0: remove existing emitter with requested_id info = Dat.Get(ParticleEmitterInfo, emitter_info_id) emitter = makeParticleEmitter() emitter.SetInfo(info) emitter.SetParenting(parent, part_index, offset) emitter.InitEnd() emitter.id = requested_id if requested_id != 0 else next_emitter_id++ particle_table.add(emitter.id, emitter) return emitter.id DestroyParticleEmitter(id): remove emitter id from particle_table StopParticleEmitter(id): emitter.stopped = true UpdateParticles(): for each emitter: keep = emitter.UpdateParticles() if !keep: remove emitter ``` `ParticleEmitter::EmitParticle` finds a free/recyclable slot, samples all random fields from the `ParticleEmitterInfo`, initializes a `Particle`, adds the particle part, and records `total_emitted`, `last_emit_time`, and `last_emit_offset`. `ParticleEmitter::UpdateParticles`: ```text if drawable/parent is valid: for each live particle: parent_frame = parent-local ? current parent frame : particle.start_frame particle.Update(parent_frame, now, persistent) if particle.lifetime >= particle.lifespan: kill particle while !stopped and info.ShouldEmitParticle(this): EmitParticle() if total_seconds != 0 and now - creation_time > total_seconds: stopped = true if total_particles != 0 and total_emitted >= total_particles: stopped = true return num_particles != 0 || !stopped ``` ## Particle integrators Every particle computes position from age/lifetime, not by accumulating Euler steps. `parent.origin` below is the parent frame origin chosen by `is_parent_local`. ```text age = now - birthtime Still: pos = parent.origin + offset LocalVelocity, GlobalVelocity: pos = parent.origin + offset + age * A ParabolicLVGA, ParabolicLVLA, ParabolicGVGA: pos = parent.origin + offset + age * A + 0.5 * age^2 * B ParabolicLVGAGR, ParabolicLVLALR, ParabolicGVGAGR: frame = parent frame.origin += offset + age * A + 0.5 * age^2 * B frame.rotate_by(age * C) pos = frame.origin Swarm: pos = parent.origin + offset + age * A pos.x += cos(age * B.x) * C.x pos.y += sin(age * B.y) * C.y pos.z += cos(age * B.z) * C.z Explode: pos.x = parent.origin.x + offset.x + (age * B.x + C.x * A.x) * age pos.y = parent.origin.y + offset.y + (age * B.y + C.y * A.x) * age pos.z = parent.origin.z + offset.z + (age * B.z + C.z * A.x + A.z) * age Implode: pos = parent.origin + offset + cos(A.x * age) * C + age^2 * B ``` `Particle::Init` resolves vector spaces once at spawn: ```text offset = transform_local_vector(random_offset, start_frame) LocalVelocity, ParabolicLVGA: A = local_to_global(A) ParabolicLVLA: A = local_to_global(A) B = local_to_global(B) ParabolicLVGAGR: A = local_to_global(A) C = C Swarm: A = local_to_global(A) Explode: A = A B = B C = normalized random direction scaled by the local C axes Implode: A = A B = B offset *= C component-wise C = offset ParabolicLVLALR: A = local_to_global(A) B = local_to_global(B) C = local_to_global(C) ParabolicGVGA, GlobalVelocity: A/B/C remain global as applicable ParabolicGVGAGR: A and B remain global C = C ``` After motion: ```text t = clamp(age / lifespan, 0, 1) scale = lerp(start_scale, final_scale, t) trans = lerp(start_trans, final_trans, t) opacity = 1 - trans ``` `StartTrans` / `FinalTrans` are transparency values, not source alpha. Retail sends the interpolated value to `PhysicsPart::SetTranslucency`; the render path uses its complement as opacity. WorldBuilder's particle renderer cross-check does the same (`opacity = 1 - currentTrans`). ## PhysicsScript and hooks `PhysicsScript::UnPack` reads ordered `(start_time, hook)` entries and sorts them by start time. The runner keeps active script instances keyed by `(script_id, entity_id)` and fires all hooks whose `start_time <= elapsed`. Hook execution: ```text CreateParticleHook: parent.create_particle_emitter(emitter_info_id, part_index, offset, emitter_id) CreateBlockingParticleHook: same particle creation path, plus sequencer blocking semantics DestroyParticleHook: parent.destroy_particle_emitter(emitter_id) StopParticleHook: parent.stop_particle_emitter(emitter_id) CallPESHook: parent.CallPES(pes_id, pause) ``` The C.1 implementation keeps hook dispatch in Core and renders the resulting particles in App. Nested `CallPESHook` stays in `PhysicsScriptRunner`, while `ParticleHookSink` converts create/destroy/stop hooks into runtime emitter handles. ## Sky integration `CelestialPosition` has both `gfx_id` and `pes_id`. Retail sky object creation copies `properties` and draws two sky cells. A named-retail recheck on 2026-04-28 corrected the original C.1 assumption: ```text SkyDesc::GetSky (0x00501ec0): copy SkyObject.gfx_id into CelestialPosition.gfx_id copy SkyObject.default_pes_object into CelestialPosition.pes_id copy properties / rotate / arc angle / tex velocity GameSky.CreateDeletePhysicsObjects (0x005073c0): for each visible CelestialPosition: post_scene = (properties & 0x01) != 0 make/update sky gfx object from gfx_id in before/after cell do not read pes_id GameSky.MakeObject (0x00506ee0): CPhysicsObj::makeObject(gfx_id, 0, 0) set texture velocity GameSky.UseTime (0x005075b0): CreateDeletePhysicsObjects() CalcFrame() set_frame / luminosity / diffusion / translucency do not read pes_id GameSky.Draw(post_scene): if post_scene == false: draw before_sky_cell else: draw after_sky_cell ``` The sky renderer must preserve the existing `0x01` pre/post split for sky meshes. `SkyObject.default_pes_object` is parsed and retained for diagnostics, but it is not a retail render-path particle source. In acdream the experimental sky-PES path is therefore gated behind `ACDREAM_ENABLE_SKY_PES=1` and disabled for normal visual comparison. ## GL rendering WorldBuilder's `ParticleBatcher` confirms the GL-side policy: ```text collect live billboard instances sort back-to-front by camera distance for alpha blending depth test enabled depth writes disabled cull disabled blend SrcAlpha/OneMinusSrcAlpha for alpha blend SrcAlpha/One for additive stream dynamic instance VBO draw instanced unit quads ``` C.1 keeps that policy and splits draw calls by particle render pass: - pre-scene sky particles after the pre-scene sky meshes - scene particles after opaque world/static objects - post-scene sky particles after post-scene sky/weather meshes