diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 6ab3a19..9b67b51 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -178,24 +178,25 @@ missing is the plugin-API surface. --- -## #2 — Lightning visual not wired (dat-baked PES triggers) +## #2 — Lightning visual mismatch (sky PES path disproved) **Status:** OPEN **Severity:** MEDIUM **Filed:** 2026-04-25 **Component:** weather / sky / vfx -**Description:** Retail's Rainy DayGroup in the Dereth Region dat contains 12+ `SkyObject` entries with non-zero `PesObjectId` and narrow visibility windows (5–70 ms at keyframe-boundary moments) that drive PhysicsScript-authored flash + thunder effects. We render the sky meshes but ignore the PES path, so no lightning flashes appear during storms. The fragment-shader flash bump on `uFogParams.z` is already wired in `sky.frag` — only the CPU-side PES→runner wire is missing. +**Description:** Lightning/storm sky visuals still do not match retail. A 2026-04-28 named-retail recheck disproved the prior assumption that `SkyObject.PesObjectId` drives sky-render flash particles: `SkyDesc::GetSky` copies the field into `CelestialPosition.pes_id`, but `GameSky::CreateDeletePhysicsObjects`, `GameSky::MakeObject`, and `GameSky::UseTime` never read it. -**Root cause / status:** Research complete. Implementation is: in `SkyRenderer.Render`, detect visibility-window entry on any SkyObject with `obj.PesObjectId != 0`, call `PhysicsScriptRunner.Play(pesObjectId, ownerId: sky-owner, anchorPos: camera)`, and route any `SetFlash` / `Sound` hooks from the script into `uFogParams.z` + audio. +**Root cause / status:** Open again. The sky-PES path is non-retail and must stay disabled for normal rendering. The remaining mismatch likely lives in the sky/weather mesh material path, the lightning/fog flash path, or another weather subsystem outside `GameSky`; do not reintroduce per-SkyObject PES playback without new decompile evidence. **Files:** -- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — add per-SkyObject PES dispatch inside the visibility loop -- `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` — already shipped (Phase 6a); exposes `Play(scriptId, entityId, anchorWorldPos)` -- `src/AcDream.Core/Lighting/SceneLightingUbo.cs` — `FogParams.Z` is the flash slot; needs a sink that bumps it and decays -- `src/AcDream.App/Rendering/Shaders/sky.frag` — flash bump already wired (`rgb += flash * vec3(1.5, 1.5, 1.8)`) +- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — sky/weather mesh draw, material state, pre/post split +- `src/AcDream.App/Rendering/Shaders/sky.frag` — flash/fog/lightning coloration path +- `src/AcDream.Core/World/SkyDescLoader.cs` — keep `PesObjectId` parsed for diagnostics, not render playback **Research:** +- `docs/research/2026-04-28-pes-pseudocode.md` — C.1 correction: `CelestialPosition.pes_id` copied but ignored by GameSky +- `docs/research/2026-04-23-sky-pes-wiring.md` — earlier decompile trace reached the same no-sky-PES conclusion - `docs/research/2026-04-23-lightning-real.md` (decompile trace + dat discovery) - `docs/research/2026-04-23-physicsscript.md` (runtime semantics) - `docs/research/2026-04-23-lightning-crossfade.md` (crossfade mechanism) @@ -281,7 +282,9 @@ missing is the plugin-API surface. **Description:** Retail renders a dynamic colored "light play" effect in the sky during certain Rainy/Cloudy DayGroup time windows. The user describes it as aurora-borealis-style. acdream renders no comparable effect. -**Root cause:** PES (Particle Effect Schedule) particles attached to SkyObjects via the `CelestialPosition.pes_id` field. Retail header at `acclient.h` line 35451 (verbatim): +**Root cause / status:** Open again. The prior root cause was wrong: `CelestialPosition.pes_id` exists in the retail header and is populated by `SkyDesc::GetSky`, but named retail `GameSky` code does not read it during sky object creation, update, or draw. A 2026-04-28 C.1 experiment that played those PES ids produced colored blobs/wash that did not match retail's broad aurora-like rays, and the path is now debug-only behind `ACDREAM_ENABLE_SKY_PES=1`. + +Retail header at `acclient.h` line 35451 still documents the copied field: ```c struct CelestialPosition { @@ -302,21 +305,24 @@ struct CelestialPosition { | 7 | 0x02000BA6 | 0x33000453 | 0.03–0.19 | early morning | | 17 | 0x02000589 | **0x3300042C** | **0.27–0.91** | **active during user's screenshot** | -acdream's geometry half is now wired (commit landing 2026-04-27 — `EnsureSetupUploaded` walks `Setup.Parts` for `0x020xxx` IDs). The dynamic visual half — emitting and animating the PES particles — is unimplemented and provides the actual aurora look. Phase E.3 already has data-only PES support per memory crib `project_session_2026_04_18.md`; this issue requires the runtime + visual half. +acdream's geometry half is now wired (commit landing 2026-04-27 — `EnsureSetupUploaded` walks `Setup.Parts` for `0x020xxx` IDs). The remaining dynamic visual half is not `SkyObject.PesObjectId`; likely suspects are sky/weather mesh material state, texture transform/blending, or a separate weather/lightning subsystem outside `GameSky`. **Implementation outline:** -1. PES dat decode (already partially in `AcDream.Core.World.PesData` per Phase E.3). -2. PES emitter runtime — schedule, spawn, advect, color-cycle, expire each particle. -3. `SkyRenderer` integration — when `MakeObject` sees `pes_id != 0`, spawn the PES at the SkyObject's celestial position. -4. PES vertex-sprite renderer — billboarded textured quads with additive blending and color cycling. Probably reuses the future general-purpose particle renderer (issue #L? — TBD). +1. Keep `SkyObject.PesObjectId` parsed for diagnostics only. +2. Compare retail/acdream material state for the active sky/weather GfxObj/Setup ids (`0x02000588`, `0x02000589`, `0x02000714`, `0x02000BA6`). +3. Trace the named retail sky/weather draw path for texture transforms, translucency, diffusion, luminosity, and any non-GameSky weather effect dispatch. +4. Only add a new runtime visual path once the decompile has an actual caller. **Decomp pointers:** -- `CPhysicsObj::InitPartArrayObject` decomp ~280484 — dispatches type 7 to Setup loader. -- `CPartArray::CreateSetup` decomp ~287490 — Setup → Parts → optional PES wiring. +- `SkyDesc::GetSky` named retail `0x00501ec0` — copies `SkyObject.default_pes_object` into `CelestialPosition.pes_id`. +- `GameSky::CreateDeletePhysicsObjects` named retail `0x005073c0` — creates/updates sky objects from `gfx_id`, does not read `pes_id`. +- `GameSky::MakeObject` named retail `0x00506ee0` — calls `CPhysicsObj::makeObject(gfx_id, 0, 0)`, no PES. +- `GameSky::UseTime` named retail `0x005075b0` — updates frame/luminosity/diffusion/translucency, no PES. **Files:** -- `src/AcDream.Core/World/SkyDescLoader.cs` — `SkyObjectData` needs to carry `PesObjectId` (currently dropped on the floor). -- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — needs a particle-emission step alongside the per-SkyObject mesh draw. +- `src/AcDream.Core/World/SkyDescLoader.cs` — carries `PesObjectId` for diagnostics. +- `src/AcDream.App/Rendering/Sky/SkyRenderer.cs` — likely material/texture-transform parity work. +- `src/AcDream.App/Rendering/GameWindow.cs` — sky-PES playback remains debug-only, disabled by default. **Acceptance:** When retail shows aurora-style light play at a specific in-game time / weather, acdream shows a visually-comparable effect at the same time. diff --git a/docs/plans/2026-04-27-phase-c1-pes-particles.md b/docs/plans/2026-04-27-phase-c1-pes-particles.md index 6251b57..e722963 100644 --- a/docs/plans/2026-04-27-phase-c1-pes-particles.md +++ b/docs/plans/2026-04-27-phase-c1-pes-particles.md @@ -4,6 +4,14 @@ **Filed:** 2026-04-27 (handoff from sky/weather session, branch merged at f7c9e88). **Worktree:** to be created at `.worktrees/phase-c1-particles` on branch `feature/phase-c1-particles`. +**2026-04-28 correction:** named-retail decompile disproves the sky-PES +premise in this spec. `SkyDesc::GetSky` copies `default_pes_object` into +`CelestialPosition.pes_id`, but `GameSky::CreateDeletePhysicsObjects` +(`0x005073c0`), `GameSky::MakeObject` (`0x00506ee0`), and +`GameSky::UseTime` (`0x005075b0`) never read it. C.1 remains valid as the +generic PhysicsScript/particle renderer for real hooks, portals, smoke, etc., +but per-SkyObject PES playback is debug-only and disabled by default. + --- ## What you're building diff --git a/docs/research/2026-04-28-pes-pseudocode.md b/docs/research/2026-04-28-pes-pseudocode.md new file mode 100644 index 0000000..ecf7f87 --- /dev/null +++ b/docs/research/2026-04-28-pes-pseudocode.md @@ -0,0 +1,345 @@ +# 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 diff --git a/docs/research/2026-04-28-sky-cloud-material-trace.md b/docs/research/2026-04-28-sky-cloud-material-trace.md new file mode 100644 index 0000000..543495e --- /dev/null +++ b/docs/research/2026-04-28-sky-cloud-material-trace.md @@ -0,0 +1,97 @@ +# 2026-04-28 Sky Cloud Material Trace + +Context: Phase C.1 originally treated the Rainy/Cloudy sky visual as a +SkyObject PES problem. Retail named-decomp and dat inspection disprove that +for the broad cloud/ray layer. + +## Retail Trace + +- `LScape::draw` (`0x00506330`) calls `GameSky::Draw(0)` before terrain and + `GameSky::Draw(1)` after terrain. +- `SkyDesc::GetSky` copies `pes_id`, but `GameSky::CreateDeletePhysicsObjects` + compares/replaces only `gfx_id` and calls `GameSky::MakeObject(gfx_id, ...)`. + The sky object PES id is not part of retail `GameSky` rendering. +- `GameSky::UseTime` applies keyframe replace fields to instantiated sky + objects: + - `0x005076e1`: `CPhysicsObj::SetLuminosity(luminosity * 0.01)` + - `0x00507715`: `CPhysicsObj::SetDiffusion(max_bright * 0.01)` + - `0x00507747`: `CPhysicsObj::SetTranslucency(transparent * 0.01)` +- `CMaterial::SetTranslucencySimple` (`0x005396f0`) writes material alpha as + `1 - translucency`. +- `CMaterial::SetDiffuseSimple` (`0x00539750`) writes material diffuse RGB. + Therefore `SkyObjectReplace.MaxBright` is diffuse, not an emissive cap. +- `D3DPolyRender::SetSurface` (`0x0059c4d0`) disables fixed-function fog alpha + whenever the raw `SurfaceType.Additive` bit is set (`0x0059c882`), even when + the earlier `Translucent + ClipMap` branch forces normal alpha blending. + +## Dat Trace + +The broad Rainy/Cloudy layer is `GfxObj 0x01004C35`, not one of the tiny +`0x020xxxxx` setup anchors: + +- `0x01004C35`: huge sky mesh, bbox roughly `20175 x 20175 x 1180`, UVs tile + across the sheet. +- Surface `0x08000023`: `Base1ClipMap | Translucent | Alpha | Additive` + (`0x00010114`), `Translucency=0.25`, `Luminosity=0`, `Diffuse=1`. +- Texture `0x060037AF`: 256x256 A8R8G8B8 cloud/ray texture. + +The setup ids observed in Rainy groups (`0x02000588`, `0x02000589`, +`0x02000BA6`, `0x02000714`) are one-part dummy anchors with tiny `0x010001EC` +geometry and default scripts/PES for sounds/flashes. They are not the broad +cloud layer. + +## Port Consequences + +- Keep per-SkyObject PES rendering debug-only until another retail path proves + it is used. +- Render `0x08000023` as final alpha blend because retail's translucent/clipmap + branch overrides the raw additive blend. +- Still disable sky fog for that surface because retail keys fog-alpha disable + off the raw `Additive` bit. +- Route `MaxBright` to diffuse (`uDiffuseFactor`) and `Luminosity` to emissive. +- Use a final opacity multiplier for material/surface transparency before the + fragment alpha write; dynamic keyframe transparency remains `1 - value`. + +## WorldBuilder Cross-Check + +Cloned upstream `https://github.com/Chorizite/WorldBuilder.git` at commit +`167788be6fce65f5ebe79eef07a0b7d28bd7aa81`. Its +`Chorizite.OpenGLSDLBackend/Lib/SkyboxRenderManager.cs` renders sky objects +camera-centered with depth off, but it is not a faithful retail oracle for sky +tint: `GameScene.cs` has the skybox render call commented out, the manager +always selects `DayGroups[0]`, and it uploads `SunlightColor = Vector3.Zero` +/ `AmbientColor = Vector3.One` for sky. `RegionInfo.cs` interpolates +DayGroup[0] lighting for terrain/world objects, not the active retail +DayGroup/weather sky. + +That explains why WorldBuilder cannot answer the missing green/purple Rainy +sky tint directly. The actionable lesson is narrower: do not fog-paint the +raw-additive cloud sheet itself. In acdream, non-additive sky layers now receive +the keyframe fog tint so the broad background wash appears behind clouds, while +surfaces with the raw Additive bit (notably `0x08000023`) keep fixed-function +fog disabled and preserve the pink cloud/ray detail. + +WorldBuilder's regular object path does collect `Setup.DefaultScript` +particle hooks (`ObjectMeshManager.CollectEmittersFromScript`) and instantiates +them via `ObjectRenderManagerBase`, but its skybox manager does not use that +setup/particle path for SkyObjects. Dat inspection also showed the canonical +Rainy default script target `0x3300042C` is a sound-loop chain (`SoundTweaked` ++ `CallPES`), not the broad green tint or cloud ray layer. + +Additional renderer lessons from upstream WorldBuilder: + +- Particle blend is material-derived. `ParticleEmitterInfo` does not carry an + additive flag; WorldBuilder reads `ObjectRenderData.Batches[0].IsAdditive` + from the particle GfxObj surface. acdream now leaves DAT emitters non-additive + by default and resolves particle blend from the selected particle surface. +- Particles must be globally sorted back-to-front before drawing. Sorting only + inside per-texture dictionaries can reorder translucent particles whenever + multiple textures/blend states are active. +- Particle quads come from the authored particle GfxObj bounds. Degenerate + extents fall back to `1.0`, and point-sprite degrade mode applies a `0.9` + base scale. +- Texture decoding must try highres `RenderSurface` records after portal lookup + and must zero alpha for black pixels on compressed clipmap textures. +- WorldBuilder tracks UV wrap and cull mode per object batch. acdream's sky path + already uses authored UV wrap, but shared object rendering still needs the + same metadata carried through a later C.4 pass. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 874aa94..5f85641 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -10,6 +10,8 @@ namespace AcDream.App.Rendering; public sealed class GameWindow : IDisposable { + private readonly record struct SkyPesKey(int ObjectIndex, uint PesObjectId, bool PostScene); + private readonly string _datDir; private readonly WorldGameState _worldGameState; private readonly WorldEvents _worldEvents; @@ -152,7 +154,7 @@ public sealed class GameWindow : IDisposable private AcDream.App.Audio.AudioHookSink? _audioSink; // Phase E.3 particles. - private readonly AcDream.Core.Vfx.EmitterDescRegistry _emitterRegistry = new(); + private AcDream.Core.Vfx.EmitterDescRegistry? _emitterRegistry; private AcDream.Core.Vfx.ParticleSystem? _particleSystem; private AcDream.Core.Vfx.ParticleHookSink? _particleSink; // Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754) @@ -160,6 +162,13 @@ public sealed class GameWindow : IDisposable // sounds, light toggles) at their StartTime offsets. private AcDream.Core.Vfx.PhysicsScriptRunner? _scriptRunner; private AcDream.App.Rendering.ParticleRenderer? _particleRenderer; + // Retail GameSky copies SkyObject.PesObjectId into CelestialPosition but + // never consumes it in CreateDeletePhysicsObjects/MakeObject/UseTime. + // Keep the experimental path available for DAT archaeology only. + private readonly bool _enableSkyPesDebug = + string.Equals(Environment.GetEnvironmentVariable("ACDREAM_ENABLE_SKY_PES"), "1", StringComparison.Ordinal); + private readonly HashSet _activeSkyPes = new(); + private readonly HashSet _missingSkyPes = new(); // Remote-entity motion inference: tracks when each remote entity last // moved meaningfully. Used in TickAnimations to swap to Ready when @@ -785,12 +794,13 @@ public sealed class GameWindow : IDisposable _dats = new DatCollection(_datDir, DatAccessType.Read); _animLoader = new AcDream.Core.Physics.DatCollectionLoader(_dats); + _emitterRegistry = new AcDream.Core.Vfx.EmitterDescRegistry(_dats); // Phase E.3 particles: always-on, no driver dependency. Registered // with the hook router so CreateParticle / DestroyParticle / // StopParticle hooks fired from motion tables produce visible // spawns. The Tick call is driven from OnRender. - _particleSystem = new AcDream.Core.Vfx.ParticleSystem(_emitterRegistry); + _particleSystem = new AcDream.Core.Vfx.ParticleSystem(_emitterRegistry!); _particleSink = new AcDream.Core.Vfx.ParticleHookSink(_particleSystem); _hookRouter.Register(_particleSink); @@ -1215,7 +1225,7 @@ public sealed class GameWindow : IDisposable // spawned into the shared ParticleSystem as billboard quads. // Weather uses AttachLocal emitters so the rain volume follows // the player. - _particleRenderer = new ParticleRenderer(_gl, shadersDir); + _particleRenderer = new ParticleRenderer(_gl, shadersDir, _textureCache, _dats); // Phase A.1: replace the one-shot 3×3 preload with a streaming controller. // Parse runtime radius from environment (default 2 → 5×5 window). @@ -2846,6 +2856,110 @@ public sealed class GameWindow : IDisposable _scriptRunner.Play(scriptId, guid, camWorldPos); } + private void UpdateSkyPes( + float dayFraction, + AcDream.Core.World.DayGroupData? dayGroup, + System.Numerics.Vector3 cameraWorldPos, + bool suppressSky) + { + if (_scriptRunner is null || _particleSink is null) + return; + + var seen = new HashSet(); + if (!suppressSky && dayGroup is not null) + { + for (int i = 0; i < dayGroup.SkyObjects.Count; i++) + { + var obj = dayGroup.SkyObjects[i]; + if (obj.PesObjectId == 0 || !obj.IsVisible(dayFraction)) + continue; + + var key = new SkyPesKey(i, obj.PesObjectId, obj.IsPostScene); + seen.Add(key); + + if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key)) + continue; + + uint skyEntityId = SkyPesEntityId(key); + var renderPass = obj.IsPostScene + ? AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene + : AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene; + _particleSink.SetEntityRenderPass(skyEntityId, renderPass); + var anchor = SkyPesAnchor(obj, cameraWorldPos); + var rotation = SkyPesRotation(obj, dayFraction); + // Refresh anchor + rotation every frame so AttachLocal + // (is_parent_local=1) particles track the camera. Retail + // ParticleEmitter::UpdateParticles at 0x0051d2d4 reads the + // live parent frame each tick; for sky-PES the parent IS + // the camera. UpdateEntityAnchor is a no-op when no + // emitters yet exist (script just spawned this frame). + _particleSink.UpdateEntityAnchor(skyEntityId, anchor, rotation); + + if (_activeSkyPes.Contains(key) || _missingSkyPes.Contains(key)) + continue; + + if (_scriptRunner.Play(obj.PesObjectId, skyEntityId, anchor)) + { + _activeSkyPes.Add(key); + } + else + { + _missingSkyPes.Add(key); + _particleSink.ClearEntityRenderPass(skyEntityId); + } + } + } + + foreach (var key in _activeSkyPes.ToArray()) + { + if (seen.Contains(key)) + continue; + + uint skyEntityId = SkyPesEntityId(key); + _scriptRunner.Stop(key.PesObjectId, skyEntityId); + _particleSink.StopAllForEntity(skyEntityId, fadeOut: true); + _activeSkyPes.Remove(key); + } + + foreach (var key in _missingSkyPes.ToArray()) + { + if (!seen.Contains(key)) + _missingSkyPes.Remove(key); + } + } + + private static uint SkyPesEntityId(SkyPesKey key) + { + // 0xF0000000 prefix marks synthetic sky-PES entityIds (no real + // server GUID lives in the 0xFxxxxxxx space). Reserve bit + // 0x08000000 for the pre/post-scene flag and the lower 27 bits + // for the object index — keeps the post-scene flag from sliding + // into the index range if a future DayGroup ever ships >65k sky + // objects (current Dereth max is 18, but the constraint is free). + uint postBit = key.PostScene ? 0x08000000u : 0u; + return 0xF0000000u | postBit | ((uint)key.ObjectIndex & 0x07FFFFFFu); + } + + private static System.Numerics.Vector3 SkyPesAnchor( + AcDream.Core.World.SkyObjectData obj, + System.Numerics.Vector3 cameraWorldPos) + { + if (obj.IsWeather && (obj.Properties & 0x08u) == 0u) + return cameraWorldPos + new System.Numerics.Vector3(0f, 0f, -120f); + + return cameraWorldPos; + } + + private static System.Numerics.Quaternion SkyPesRotation( + AcDream.Core.World.SkyObjectData obj, + float dayFraction) + { + float rotationRad = obj.CurrentAngle(dayFraction) * (MathF.PI / 180f); + return System.Numerics.Quaternion.CreateFromAxisAngle( + System.Numerics.Vector3.UnitY, + -rotationRad); + } + /// /// Phase 5d — retail AdminEnvirons (0xEA60) dispatcher. /// Routes fog presets into the weather system's sticky override @@ -4329,6 +4443,7 @@ public sealed class GameWindow : IDisposable // interpolated keyframe. var kf = WorldTime.CurrentSky; var atmo = Weather.Snapshot(in kf); + bool environOverrideActive = atmo.Override != AcDream.Core.World.EnvironOverride.None; var fogColor = atmo.FogColor; // Clear to fog color (horizon haze) so if sky meshes have alpha // gaps or don't cover the full view, the "missing" area reads as @@ -4379,15 +4494,6 @@ public sealed class GameWindow : IDisposable // and the SkyRenderer.RenderWeather pass both pick up snow // weather meshes for free.) - // Phase E.3: advance live particle emitters AFTER animation tick - // so emitters spawned by hooks fired this frame get integrated. - // Tick the PhysicsScript runner BEFORE the particle system so any - // CreateParticleHook fired this frame has its emitter alive when - // the particle system advances. - _scriptRunner?.Tick((float)deltaSeconds); - - _particleSystem?.Tick((float)deltaSeconds); - int visibleLandblocks = 0; int totalLandblocks = 0; @@ -4455,6 +4561,15 @@ public sealed class GameWindow : IDisposable var visibility = _cellVisibility.ComputeVisibility(camPos); bool cameraInsideCell = visibility?.CameraCell is not null; + // Phase C.1: tick retail PhysicsScript particle hooks. Named + // retail decomp confirms SkyObject.PesObjectId is copied by + // SkyDesc::GetSky but ignored by GameSky, so the sky-PES path is + // debug-only and disabled for normal retail rendering. + if (_enableSkyPesDebug) + UpdateSkyPes((float)WorldTime.DayFraction, _activeDayGroup, camPos, cameraInsideCell); + _scriptRunner?.Tick((float)deltaSeconds); + _particleSystem?.Tick((float)deltaSeconds); + // Phase G.1/G.2: feed the sun, tick LightManager, build + upload // the scene-lighting UBO once per frame. Every shader that // consumes binding=1 reads the same data for the rest of the @@ -4490,7 +4605,10 @@ public sealed class GameWindow : IDisposable if (!cameraInsideCell) { _skyRenderer?.RenderSky(camera, camPos, (float)WorldTime.DayFraction, - _activeDayGroup, kf); + _activeDayGroup, kf, environOverrideActive); + if (_particleSystem is not null && _particleRenderer is not null) + _particleRenderer.Draw(_particleSystem, camera, camPos, + AcDream.Core.Vfx.ParticleRenderPass.SkyPreScene); } // K-fix1 (2026-04-26): suppress terrain + entity rendering @@ -4523,7 +4641,8 @@ public sealed class GameWindow : IDisposable // Runs with depth test on (particles occluded by walls) // but depth write off (no self-occlusion sorting needed). if (_particleSystem is not null && _particleRenderer is not null) - _particleRenderer.Draw(_particleSystem, camera, camPos); + _particleRenderer.Draw(_particleSystem, camera, camPos, + AcDream.Core.Vfx.ParticleRenderPass.Scene); // Bug A fix (post-#26 worktree, 2026-04-26): weather sky // meshes (Properties & 0x04, e.g. the 815m-tall rain @@ -4536,7 +4655,10 @@ public sealed class GameWindow : IDisposable if (!cameraInsideCell) { _skyRenderer?.RenderWeather(camera, camPos, (float)WorldTime.DayFraction, - _activeDayGroup, kf); + _activeDayGroup, kf, environOverrideActive); + if (_particleSystem is not null && _particleRenderer is not null) + _particleRenderer.Draw(_particleSystem, camera, camPos, + AcDream.Core.Vfx.ParticleRenderPass.SkyPostScene); } // Debug: draw collision shapes as wireframe cylinders around the diff --git a/src/AcDream.App/Rendering/ParticleRenderer.cs b/src/AcDream.App/Rendering/ParticleRenderer.cs index 7128694..61ef0bd 100644 --- a/src/AcDream.App/Rendering/ParticleRenderer.cs +++ b/src/AcDream.App/Rendering/ParticleRenderer.cs @@ -2,64 +2,69 @@ using System; using System.Collections.Generic; using System.Numerics; using AcDream.Core.Vfx; +using DatReaderWriter; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Enums; using Silk.NET.OpenGL; namespace AcDream.App.Rendering; /// -/// Simple billboard-quad particle renderer. One draw call per emitter: -/// the CPU streams (position, size, rotation, packed color) into a -/// per-instance VBO; a unit quad VBO gets instanced and the vertex -/// shader rotates the quad around the camera forward vector so it -/// always faces the viewer. -/// -/// -/// Not a retail-perfect port of the D3D7 fixed-function particle pipe; -/// good enough for rain, snow, and the basic spell auras we need for -/// Phase G.1's weather + E.3's playback. Trails + spot-light -/// interactions deferred. -/// -/// -/// -/// Emitters tagged with get -/// re-anchored to the current camera position each frame so the rain -/// volume follows the player (r12 §7). This is the cheap version of -/// retail's "IsParentLocal" flag on held emitters. -/// +/// Instanced renderer for retail particle emitters. /// public sealed unsafe class ParticleRenderer : IDisposable { + private readonly record struct BatchKey(uint TextureHandle, bool UseTexture, bool Additive); + private readonly record struct ParticleDraw(BatchKey Key, ParticleInstance Instance); + + private readonly struct ParticleInstance + { + public readonly Vector3 Position; + public readonly Vector3 AxisX; + public readonly Vector3 AxisY; + public readonly uint ColorArgb; + public readonly float DistanceSq; + + public ParticleInstance(Vector3 position, Vector3 axisX, Vector3 axisY, uint colorArgb, float distanceSq) + { + Position = position; + AxisX = axisX; + AxisY = axisY; + ColorArgb = colorArgb; + DistanceSq = distanceSq; + } + } + private readonly GL _gl; private readonly Shader _shader; + private readonly TextureCache? _textures; + private readonly DatCollection? _dats; + private readonly Dictionary _particleGfxInfoByGfxObj = new(); - // Unit-quad vertex buffer (-0.5..+0.5 in XY). 4 verts, 6 indices. private readonly uint _quadVao; private readonly uint _quadVbo; private readonly uint _quadEbo; - - // Instance buffer — 8 floats per particle: posX,Y,Z, size, colorR,G,B,A. private readonly uint _instanceVbo; - private float[] _instanceScratch = new float[256 * 8]; - public ParticleRenderer(GL gl, string shadersDir) + private float[] _instanceScratch = new float[256 * 16]; + + public ParticleRenderer(GL gl, string shadersDir, TextureCache? textures = null, DatCollection? dats = null) { _gl = gl ?? throw new ArgumentNullException(nameof(gl)); + _textures = textures; + _dats = dats; _shader = new Shader(_gl, System.IO.Path.Combine(shadersDir, "particle.vert"), System.IO.Path.Combine(shadersDir, "particle.frag")); - // Unit quad around origin (XY plane, Z = 0). The vertex shader - // reads this, then offsets into world space using the - // per-instance (pos, size) values. - float[] quadVerts = new float[] + float[] quadVerts = { - // pos x,y uv -0.5f, -0.5f, 0f, 0f, 0.5f, -0.5f, 1f, 0f, 0.5f, 0.5f, 1f, 1f, -0.5f, 0.5f, 0f, 1f, }; - uint[] quadIdx = new uint[] { 0, 1, 2, 0, 2, 3 }; + uint[] quadIdx = { 0, 1, 2, 0, 2, 3 }; _quadVao = _gl.GenVertexArray(); _gl.BindVertexArray(_quadVao); @@ -67,8 +72,14 @@ public sealed unsafe class ParticleRenderer : IDisposable _quadVbo = _gl.GenBuffer(); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _quadVbo); fixed (void* p = quadVerts) - _gl.BufferData(BufferTargetARB.ArrayBuffer, - (nuint)(quadVerts.Length * sizeof(float)), p, BufferUsageARB.StaticDraw); + { + _gl.BufferData( + BufferTargetARB.ArrayBuffer, + (nuint)(quadVerts.Length * sizeof(float)), + p, + BufferUsageARB.StaticDraw); + } + _gl.EnableVertexAttribArray(0); _gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 4 * sizeof(float), (void*)0); _gl.EnableVertexAttribArray(1); @@ -77,135 +88,347 @@ public sealed unsafe class ParticleRenderer : IDisposable _quadEbo = _gl.GenBuffer(); _gl.BindBuffer(BufferTargetARB.ElementArrayBuffer, _quadEbo); fixed (void* p = quadIdx) - _gl.BufferData(BufferTargetARB.ElementArrayBuffer, - (nuint)(quadIdx.Length * sizeof(uint)), p, BufferUsageARB.StaticDraw); + { + _gl.BufferData( + BufferTargetARB.ElementArrayBuffer, + (nuint)(quadIdx.Length * sizeof(uint)), + p, + BufferUsageARB.StaticDraw); + } _instanceVbo = _gl.GenBuffer(); _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); - _gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)(256 * 8 * sizeof(float)), - (void*)0, BufferUsageARB.DynamicDraw); + _gl.BufferData(BufferTargetARB.ArrayBuffer, (nuint)(256 * 16 * sizeof(float)), (void*)0, BufferUsageARB.DynamicDraw); - // Per-instance attributes: pos+size at loc 2, color at loc 3. _gl.EnableVertexAttribArray(2); - _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, 8 * sizeof(float), (void*)0); + _gl.VertexAttribPointer(2, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)0); _gl.VertexAttribDivisor(2, 1); _gl.EnableVertexAttribArray(3); - _gl.VertexAttribPointer(3, 4, VertexAttribPointerType.Float, false, 8 * sizeof(float), (void*)(4 * sizeof(float))); + _gl.VertexAttribPointer(3, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)(4 * sizeof(float))); _gl.VertexAttribDivisor(3, 1); + _gl.EnableVertexAttribArray(4); + _gl.VertexAttribPointer(4, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)(8 * sizeof(float))); + _gl.VertexAttribDivisor(4, 1); + _gl.EnableVertexAttribArray(5); + _gl.VertexAttribPointer(5, 4, VertexAttribPointerType.Float, false, 16 * sizeof(float), (void*)(12 * sizeof(float))); + _gl.VertexAttribDivisor(5, 1); _gl.BindVertexArray(0); } - /// - /// Draw every live particle. Splits emitters by blend mode (additive - /// vs alpha-blend) but doesn't sort by depth — particles don't - /// self-occlude enough for sorting to matter for rain/snow. - /// - public void Draw(ParticleSystem particles, ICamera camera, Vector3 cameraWorldPos) + public void Draw( + ParticleSystem particles, + ICamera camera, + Vector3 cameraWorldPos, + ParticleRenderPass renderPass = ParticleRenderPass.Scene) { - if (particles is null || camera is null) return; + if (particles is null || camera is null) + return; + + Matrix4x4.Invert(camera.View, out var invView); + Vector3 cameraRight = Vector3.Normalize(new Vector3(invView.M11, invView.M12, invView.M13)); + Vector3 cameraUp = Vector3.Normalize(new Vector3(invView.M21, invView.M22, invView.M23)); + var draws = BuildDrawList(particles, cameraWorldPos, renderPass, cameraRight, cameraUp); + if (draws.Count == 0) + return; + draws.Sort(static (a, b) => b.Instance.DistanceSq.CompareTo(a.Instance.DistanceSq)); _shader.Use(); _shader.SetMatrix4("uViewProjection", camera.View * camera.Projection); - _shader.SetVec3("uCameraRight", GetCameraRight(camera)); - _shader.SetVec3("uCameraUp", GetCameraUp(camera)); + _shader.SetInt("uParticleTexture", 0); + _gl.Enable(EnableCap.DepthTest); _gl.Enable(EnableCap.Blend); _gl.DepthMask(false); _gl.Disable(EnableCap.CullFace); + _gl.ActiveTexture(TextureUnit.Texture0); - // Group emitters by additive vs alpha-blend so we flip blend state - // once per group rather than per-emitter. Simple two-pass split. - var alphaGroup = new List(32); - var addGroup = new List(32); - foreach (var (em, _) in particles.EnumerateLive()) + var run = new List(64); + for (int i = 0; i < draws.Count;) { - var list = (em.Desc.Flags & EmitterFlags.Additive) != 0 ? addGroup : alphaGroup; - if (list.Count == 0 || !ReferenceEquals(list[^1], em)) - list.Add(em); + var key = draws[i].Key; + run.Clear(); + do + { + run.Add(draws[i].Instance); + i++; + } + while (i < draws.Count && draws[i].Key == key); + + _gl.BlendFunc( + BlendingFactor.SrcAlpha, + key.Additive ? BlendingFactor.One : BlendingFactor.OneMinusSrcAlpha); + _shader.SetInt("uUseTexture", key.UseTexture ? 1 : 0); + _gl.BindTexture(TextureTarget.Texture2D, key.UseTexture ? key.TextureHandle : 0); + DrawInstances(run); } - _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); - foreach (var em in alphaGroup) - DrawEmitter(em, cameraWorldPos); - - _gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.One); - foreach (var em in addGroup) - DrawEmitter(em, cameraWorldPos); - + _gl.BindTexture(TextureTarget.Texture2D, 0); + _gl.BindVertexArray(0); _gl.DepthMask(true); _gl.Disable(EnableCap.Blend); - _gl.BindVertexArray(0); } - private void DrawEmitter(ParticleEmitter em, Vector3 cameraWorldPos) + private List BuildDrawList( + ParticleSystem particles, + Vector3 cameraWorldPos, + ParticleRenderPass renderPass, + Vector3 cameraRight, + Vector3 cameraUp) { - int liveCount = 0; - for (int i = 0; i < em.Particles.Length; i++) - if (em.Particles[i].Alive) liveCount++; - if (liveCount == 0) return; - - // Ensure instance buffer is big enough. - int needed = liveCount * 8; - if (_instanceScratch.Length < needed) - _instanceScratch = new float[needed + 256 * 8]; - - // Anchor adjustment for AttachLocal emitters — re-center the - // emission volume on the camera each frame so the rain/snow - // follows the viewer. The emitter's AnchorPos stays at the - // spawn point, but when writing out world-space particles we - // add (camera - emitterAnchor) so they track the camera. - bool attachLocal = (em.Desc.Flags & EmitterFlags.AttachLocal) != 0; - Vector3 cameraOffset = attachLocal ? (cameraWorldPos - em.AnchorPos) : Vector3.Zero; - - int idx = 0; - for (int i = 0; i < em.Particles.Length; i++) + var draws = new List(Math.Max(64, particles.ActiveParticleCount)); + foreach (var (em, idx) in particles.EnumerateLive()) { - ref var p = ref em.Particles[i]; - if (!p.Alive) continue; + if (em.RenderPass != renderPass) + continue; - Vector3 pos = p.Position + cameraOffset; - _instanceScratch[idx * 8 + 0] = pos.X; - _instanceScratch[idx * 8 + 1] = pos.Y; - _instanceScratch[idx * 8 + 2] = pos.Z; - _instanceScratch[idx * 8 + 3] = p.Size; + ref var p = ref em.Particles[idx]; + // `p.Position` is already in world coordinates: AttachLocal + // emitters get their AnchorPos refreshed each frame by the + // owning subsystem (sky-PES driver, animation tick, etc.) which + // mirrors retail's live-parent-frame read at + // ParticleEmitter::UpdateParticles 0x0051d2d4 for is_parent_local=1. + Vector3 pos = p.Position; + float distSq = Vector3.DistanceSquared(pos, cameraWorldPos); + var gfxInfo = ResolveParticleGfxInfo(em.Desc); + uint texture = gfxInfo.TextureHandle; + bool useTexture = texture != 0; + bool additive = gfxInfo.HasMaterial + ? gfxInfo.Additive + : (em.Desc.Flags & EmitterFlags.Additive) != 0; + var key = new BatchKey(texture, useTexture, additive); + Vector3 axisX; + Vector3 axisY; + if (gfxInfo.IsBillboard) + { + pos += Vector3.UnitZ * (gfxInfo.CenterOffset.Z * p.Size); + axisX = cameraRight * (gfxInfo.Size.X * p.Size); + axisY = cameraUp * (gfxInfo.Size.Y * p.Size); + } + else + { + Quaternion orientation = ParticleOrientation(em, p); + pos += Vector3.Transform(gfxInfo.CenterOffset * p.Size, orientation); + axisX = Vector3.Transform(gfxInfo.AxisX, orientation) * (gfxInfo.Size.X * p.Size); + axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size); + } - // ARGB → RGBA floats. - float a = ((p.ColorArgb >> 24) & 0xFF) / 255f; - float r = ((p.ColorArgb >> 16) & 0xFF) / 255f; - float g = ((p.ColorArgb >> 8) & 0xFF) / 255f; - float b = ( p.ColorArgb & 0xFF) / 255f; - _instanceScratch[idx * 8 + 4] = r; - _instanceScratch[idx * 8 + 5] = g; - _instanceScratch[idx * 8 + 6] = b; - _instanceScratch[idx * 8 + 7] = a; + draws.Add(new ParticleDraw(key, new ParticleInstance(pos, axisX, axisY, p.ColorArgb, distSq))); + } - idx++; + return draws; + } + + private void DrawInstances(List instances) + { + if (instances.Count == 0) + return; + + int needed = instances.Count * 16; + if (_instanceScratch.Length < needed) + _instanceScratch = new float[needed + 256 * 16]; + + for (int i = 0; i < instances.Count; i++) + { + var p = instances[i]; + int o = i * 16; + _instanceScratch[o + 0] = p.Position.X; + _instanceScratch[o + 1] = p.Position.Y; + _instanceScratch[o + 2] = p.Position.Z; + _instanceScratch[o + 3] = 0f; + + _instanceScratch[o + 4] = p.AxisX.X; + _instanceScratch[o + 5] = p.AxisX.Y; + _instanceScratch[o + 6] = p.AxisX.Z; + _instanceScratch[o + 7] = 0f; + + _instanceScratch[o + 8] = p.AxisY.X; + _instanceScratch[o + 9] = p.AxisY.Y; + _instanceScratch[o + 10] = p.AxisY.Z; + _instanceScratch[o + 11] = 0f; + + _instanceScratch[o + 12] = ((p.ColorArgb >> 16) & 0xFF) / 255f; + _instanceScratch[o + 13] = ((p.ColorArgb >> 8) & 0xFF) / 255f; + _instanceScratch[o + 14] = (p.ColorArgb & 0xFF) / 255f; + _instanceScratch[o + 15] = ((p.ColorArgb >> 24) & 0xFF) / 255f; } _gl.BindBuffer(BufferTargetARB.ArrayBuffer, _instanceVbo); fixed (void* bp = _instanceScratch) { - _gl.BufferData(BufferTargetARB.ArrayBuffer, - (nuint)(liveCount * 8 * sizeof(float)), - bp, BufferUsageARB.DynamicDraw); + _gl.BufferData( + BufferTargetARB.ArrayBuffer, + (nuint)(instances.Count * 16 * sizeof(float)), + bp, + BufferUsageARB.DynamicDraw); } _gl.BindVertexArray(_quadVao); - _gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, - DrawElementsType.UnsignedInt, (void*)0, (uint)liveCount); + _gl.DrawElementsInstanced(PrimitiveType.Triangles, 6, DrawElementsType.UnsignedInt, (void*)0, (uint)instances.Count); } - private static Vector3 GetCameraRight(ICamera camera) + private ParticleGfxInfo ResolveParticleGfxInfo(EmitterDesc desc) { - Matrix4x4.Invert(camera.View, out var inv); - return Vector3.Normalize(new Vector3(inv.M11, inv.M12, inv.M13)); + if (_textures is null) + return ParticleGfxInfo.Default; + + if (desc.TextureSurfaceId != 0) + return ParticleGfxInfo.Billboard( + _textures.GetOrUpload(desc.TextureSurfaceId), + Vector2.One, + Vector3.Zero, + additive: (desc.Flags & EmitterFlags.Additive) != 0, + hasMaterial: false); + + uint gfxObjId = desc.HwGfxObjId != 0 ? desc.HwGfxObjId : desc.GfxObjId; + if (gfxObjId == 0 || _dats is null) + return ParticleGfxInfo.Default; + + if (!_particleGfxInfoByGfxObj.TryGetValue(gfxObjId, out var info)) + { + info = ReadParticleGfxInfo(gfxObjId); + _particleGfxInfoByGfxObj[gfxObjId] = info; + } + + return info.TextureHandle != 0 ? info : ParticleGfxInfo.Default; } - private static Vector3 GetCameraUp(ICamera camera) + private ParticleGfxInfo ReadParticleGfxInfo(uint gfxObjId) { - Matrix4x4.Invert(camera.View, out var inv); - return Vector3.Normalize(new Vector3(inv.M21, inv.M22, inv.M23)); + try + { + var gfx = _dats?.Get(gfxObjId); + if (gfx is null) + return ParticleGfxInfo.Default; + + uint surfaceId = gfx.Surfaces.Count > 0 ? gfx.Surfaces[0].DataId : 0u; + uint texture = surfaceId != 0 && _textures is not null ? _textures.GetOrUpload(surfaceId) : 0u; + bool additive = false; + if (surfaceId != 0) + { + var surface = _dats?.Get(surfaceId); + additive = surface is not null && surface.Type.HasFlag(SurfaceType.Additive); + } + return AuthoredParticleGfxInfo(gfx, texture, additive, surfaceId != 0); + } + catch + { + return ParticleGfxInfo.Default; + } + } + + private ParticleGfxInfo AuthoredParticleGfxInfo(GfxObj gfx, uint texture, bool additive, bool hasMaterial) + { + if (gfx.VertexArray.Vertices.Count == 0) + return ParticleGfxInfo.Billboard(texture, Vector2.One, Vector3.Zero, additive, hasMaterial); + + var min = new Vector3(float.PositiveInfinity); + var max = new Vector3(float.NegativeInfinity); + foreach (var (_, v) in gfx.VertexArray.Vertices) + { + min = Vector3.Min(min, v.Origin); + max = Vector3.Max(max, v.Origin); + } + + var size = max - min; + var center = (min + max) * 0.5f; + if (IsPointSprite(gfx)) + { + float sx = FallbackParticleExtent(size.X) * 0.9f; + float sy = FallbackParticleExtent(size.Z) * 0.9f; + return ParticleGfxInfo.Billboard(texture, new Vector2(sx, sy), center, additive, hasMaterial); + } + + Vector3 axisX; + Vector3 axisY; + Vector2 planeSize; + if (size.Y > size.X && size.Y > size.Z) + { + if (size.X > size.Z) + { + axisX = Vector3.UnitX; + axisY = Vector3.UnitY; + planeSize = new Vector2(size.X, size.Y); + } + else + { + axisX = Vector3.UnitY; + axisY = Vector3.UnitZ; + planeSize = new Vector2(size.Y, size.Z); + } + } + else if (size.X > size.Y && size.X > size.Z) + { + if (size.Z > size.Y) + { + axisX = Vector3.UnitX; + axisY = Vector3.UnitZ; + planeSize = new Vector2(size.X, size.Z); + } + else + { + axisX = Vector3.UnitX; + axisY = Vector3.UnitY; + planeSize = new Vector2(size.X, size.Y); + } + } + else + { + if (size.X > size.Y) + { + axisX = Vector3.UnitX; + axisY = Vector3.UnitZ; + planeSize = new Vector2(size.X, size.Z); + } + else + { + axisX = Vector3.UnitY; + axisY = Vector3.UnitZ; + planeSize = new Vector2(size.Y, size.Z); + } + } + + planeSize.X = FallbackParticleExtent(planeSize.X); + planeSize.Y = FallbackParticleExtent(planeSize.Y); + return new ParticleGfxInfo(texture, planeSize, axisX, axisY, center, false, additive, hasMaterial); + } + + private bool IsPointSprite(GfxObj gfx) + { + if (!gfx.Flags.HasFlag(GfxObjFlags.HasDIDDegrade) || gfx.DIDDegrade == 0 || _dats is null) + return false; + + try + { + var degrade = _dats.Get(gfx.DIDDegrade); + return degrade?.Degrades.Count > 0 && degrade.Degrades[0].DegradeMode == 2; + } + catch + { + return false; + } + } + + private static float FallbackParticleExtent(float value) + => value > 1e-4f ? Math.Clamp(value, 1e-4f, 10_000f) : 1f; + + private static Quaternion ParticleOrientation(AcDream.Core.Vfx.ParticleEmitter em, Particle p) + { + Quaternion orientation = (em.Desc.Flags & EmitterFlags.AttachLocal) != 0 + ? em.AnchorRot + : p.SpawnRotation; + + if (em.Desc.Type is AcDream.Core.Vfx.ParticleType.ParabolicLVGAGR + or AcDream.Core.Vfx.ParticleType.ParabolicLVLALR + or AcDream.Core.Vfx.ParticleType.ParabolicGVGAGR) + { + Vector3 angular = p.C * p.Age; + float radians = angular.Length(); + if (radians > 1e-6f) + orientation = Quaternion.Normalize(orientation * Quaternion.CreateFromAxisAngle(angular / radians, radians)); + } + + return orientation; } public void Dispose() @@ -216,4 +439,26 @@ public sealed unsafe class ParticleRenderer : IDisposable _gl.DeleteVertexArray(_quadVao); _shader.Dispose(); } + + private readonly record struct ParticleGfxInfo( + uint TextureHandle, + Vector2 Size, + Vector3 AxisX, + Vector3 AxisY, + Vector3 CenterOffset, + bool IsBillboard, + bool Additive, + bool HasMaterial) + { + public static ParticleGfxInfo Default { get; } = + Billboard(0u, Vector2.One, Vector3.Zero, additive: false, hasMaterial: false); + + public static ParticleGfxInfo Billboard( + uint textureHandle, + Vector2 size, + Vector3 centerOffset, + bool additive, + bool hasMaterial) => + new(textureHandle, size, Vector3.UnitX, Vector3.UnitY, centerOffset, true, additive, hasMaterial); + } } diff --git a/src/AcDream.App/Rendering/Shaders/particle.frag b/src/AcDream.App/Rendering/Shaders/particle.frag index 4633285..7fb908d 100644 --- a/src/AcDream.App/Rendering/Shaders/particle.frag +++ b/src/AcDream.App/Rendering/Shaders/particle.frag @@ -4,15 +4,23 @@ in vec2 vTex; in vec4 vColor; out vec4 fragColor; -// Procedural rain/snow streak — no texture, just a radial falloff -// centred on the quad so droplets read as small soft circles. Good -// enough for weather + basic spell auras without a texture pipeline. +uniform sampler2D uParticleTexture; +uniform bool uUseTexture; void main() { - // Signed distance from quad center (in UV space). - vec2 d = vTex - vec2(0.5, 0.5); - float r = length(d) * 2.0; // 0 at center, 1 at corner - float falloff = smoothstep(1.0, 0.4, r); - if (falloff < 0.02) discard; - fragColor = vec4(vColor.rgb, vColor.a * falloff); + vec4 texel; + if (uUseTexture) { + texel = texture(uParticleTexture, vTex); + } else { + vec2 d = vTex - vec2(0.5, 0.5); + float r = length(d) * 2.0; + float falloff = smoothstep(1.0, 0.4, r); + texel = vec4(1.0, 1.0, 1.0, falloff); + } + + vec4 color = texel * vColor; + if (color.a < 0.02) + discard; + + fragColor = color; } diff --git a/src/AcDream.App/Rendering/Shaders/particle.vert b/src/AcDream.App/Rendering/Shaders/particle.vert index 7b26dbf..6b45a70 100644 --- a/src/AcDream.App/Rendering/Shaders/particle.vert +++ b/src/AcDream.App/Rendering/Shaders/particle.vert @@ -4,26 +4,21 @@ layout(location = 0) in vec2 aQuad; layout(location = 1) in vec2 aTex; -// Per-instance: world-space center + size -layout(location = 2) in vec4 aPosAndSize; -layout(location = 3) in vec4 aColor; +// Per-instance: world-space center, authored sheet axes, color. +layout(location = 2) in vec4 aCenter; +layout(location = 3) in vec4 aAxisX; +layout(location = 4) in vec4 aAxisY; +layout(location = 5) in vec4 aColor; uniform mat4 uViewProjection; -uniform vec3 uCameraRight; -uniform vec3 uCameraUp; out vec2 vTex; out vec4 vColor; void main() { - vec3 center = aPosAndSize.xyz; - float size = aPosAndSize.w; - - // Billboard: offset the quad vertex along the camera's right + up - // basis vectors so it always faces the viewer. - vec3 world = center - + uCameraRight * (aQuad.x * size) - + uCameraUp * (aQuad.y * size); + vec3 world = aCenter.xyz + + aAxisX.xyz * aQuad.x + + aAxisY.xyz * aQuad.y; vTex = aTex; vColor = aColor; diff --git a/src/AcDream.App/Rendering/Shaders/sky.frag b/src/AcDream.App/Rendering/Shaders/sky.frag index c704467..a9b3d16 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.frag +++ b/src/AcDream.App/Rendering/Shaders/sky.frag @@ -1,46 +1,15 @@ #version 430 core -// Sky mesh fragment shader — final composite matching retail's -// D3D fixed-function: -// -// fragment.rgb = texture.rgb × vTint + lightning_flash -// fragment.a = texture.a × (1 - uTransparency) × uSurfTranslucency -// (uSurfTranslucency is OPACITY directly per retail's -// D3DPolyRender::SetSurface at 0x59c7a6, NOT 1-x) -// -// vTint arrives from the vertex shader with retail's per-vertex -// lighting formula baked in (Emissive + lightAmbient + lightDiffuse × -// max(N·L, 0)) — see sky.vert for the decompile citation. The keyframe -// SkyObjectReplace.Luminosity override is folded into uEmissive on the -// CPU side (SkyRenderer.cs) so vTint already saturates properly for -// bright keyframes; the previous shader had a redundant uLuminosity -// multiply that was double-dimming clouds, removed 2026-04-26. -// -// See `docs/research/2026-04-23-sky-material-state.md`. in vec2 vTex; in vec3 vTint; -in float vFogFactor; // 1 = no fog (near), 0 = full fog color (far) +in float vFogFactor; // 1 = no fog, 0 = full fog color out vec4 fragColor; uniform sampler2D uDiffuse; -uniform float uTransparency; // 0 = fully visible, 1 = fully transparent -// 1.0 = apply fog mix to this submesh; 0.0 = skip fog (Additive sky -// surfaces — sun/moon/stars per retail SetFFFogAlphaDisabled(1) at -// D3DPolyRender::SetSurface 0x59c882). Set per-submesh on the CPU side. -uniform float uApplyFog; -// Surface.Translucency float (0..1) used DIRECTLY as opacity (NOT 1-x). -// Distinct from uTransparency (per-keyframe Replace override). Retail -// D3DPolyRender::SetSurface at 0x59c7a6 (decomp 425255-425260) reads -// Surface.Translucency when the Translucent (0x10) bit is set and feeds -// _ftol2(translucency × 255) directly as vertex alpha. ACViewer -// (TextureCache.cs:142) + WorldBuilder (ObjectMeshManager.cs:1115) both -// invert it (1-x) and are wrong. For non-Translucent surfaces the CPU -// side (GfxObjMesh.Build) sets uSurfTranslucency = 1.0 ⇒ no effect. -uniform float uSurfTranslucency; +uniform float uTransparency; // keyframe transparency: 0 visible, 1 transparent +uniform float uApplyFog; // 1 for foggable sky layers; raw-additive surfaces keep retail fog disabled +uniform float uSurfOpacity; // final surface opacity multiplier from the CPU -// Shared SceneLighting UBO — fog params drive the mix, flash channel -// bumps sky brightness during lightning strikes. Matches sky.vert's -// declaration exactly. struct Light { vec4 posAndKind; vec4 dirAndRange; @@ -58,79 +27,21 @@ layout(std140, binding = 1) uniform SceneLighting { void main() { vec4 sampled = texture(uDiffuse, vTex); - // Composite: texture × per-vertex lit. Replace.Luminosity (per - // keyframe) and Surface.Luminosity are both folded into uEmissive - // on the CPU side (SkyRenderer.cs) so vTint already carries the - // right tint for the time-of-day. Retail's fragment formula - // (FUN_0059da60 non-luminous branch) is texture × litColor × - // vertex.color(=white), so `texture × vTint` is the retail-faithful - // composite. vec3 rgb = sampled.rgb * vTint; - // Retail-faithful sky fog mix with a "fog floor" mitigation: - // - // Dereth sky meshes are authored at radii 1050–1820m. At midnight - // (storm keyframes FogEnd ~400m) the raw vFogFactor saturates to 0 - // for every dome pixel — `mix(fogColor, rgb, 0)` would render the - // entire dome as flat fogColor, destroying stars / moon / texture. - // That was the reason fog was disabled on sky 2026-04-24 (issue #4). - // - // Retail clearly DOES apply fog to its sky meshes — distant horizon - // mountains and the dome itself fade toward the fog color in retail - // screenshots. Mechanism unknown (sky-specific FogEnd? elevation- - // weighted? different formula?). Until pinned, the workaround is - // a clamp on the minimum fog factor so the dome NEVER mixes more - // than (1 - SKY_FOG_FLOOR) toward fogColor — preserves stars/moon - // while still letting the horizon haze visibly in low-FogEnd - // keyframes. - // - // SKY_FOG_FLOOR=0.2 means dome shows AT LEAST 20% raw texture, AT - // MOST 80% fog color even at extreme distances. Tuned via dual- - // client visual comparison 2026-04-27 — adjust if night sky goes - // back to flat-fog or stays too vivid vs retail. - // Skip fog mix entirely on Additive surfaces (sun, moon, stars, - // additive cloud sheets) — retail's SetFFFogAlphaDisabled(1) at - // D3DPolyRender::SetSurface 0x59c882. Without this gate the sun - // dims to fog color at horizon, which doesn't match retail. if (uApplyFog > 0.5) { const float SKY_FOG_FLOOR = 0.2; float skyFogFactor = max(vFogFactor, SKY_FOG_FLOOR); rgb = mix(uFogColor.rgb, rgb, skyFogFactor); } - // Lightning additive bump — client-driven during storm flashes. - // NOTE: the exact retail mechanism for lightning visual is still - // under research (agent #5, 2026-04-23). Keeping the uFogParams.z - // channel wired so if it ends up being a per-frame flash uniform - // that's what it becomes; if lightning turns out to be a particle - // system effect instead, this bump becomes a no-op (flash stays 0). float flash = uFogParams.z; rgb += flash * vec3(1.5, 1.5, 1.8); - // Normal-frame cap at 1.0 (retail D3D framebuffer clamps per-channel - // on output). Flash relaxes ceiling to 3.0 so storm strobes blow - // out visibly. float cap = mix(1.0, 3.0, clamp(flash, 0.0, 1.0)); rgb = min(rgb, vec3(cap)); - // Final fragment alpha: - // uTransparency — keyframe-replace transparency override (0..1). - // 0 = fully visible, 1 = fully transparent. - // Applied as (1 - x). - // uSurfTranslucency — the dat's Surface.Translucency value when the - // Translucent flag is set, else 1.0. Despite the - // name, retail uses this as OPACITY directly (per - // D3DPolyRender::SetSurface at 0x59c7a6 which - // writes _ftol2(translucency × 255) into vertex - // alpha). Multiply directly — NOT (1 - x). - // - // For the rain mesh 0x01004C42/4C44 (translucency=0.5): a = 1*1*0.5 = 0.5 - // matches retail curr_alpha=127, halves the additive streak. - // For cloud surface 0x08000023 (translucency=0.25): a = 1*1*0.25 = 0.25 - // matches retail curr_alpha=63, dim cloud (was 3× too bright with - // the previous 1-x formula). - // For non-Translucent surfaces uSurfTranslucency = 1.0, no effect. - float a = sampled.a * (1.0 - uTransparency) * uSurfTranslucency; + float a = sampled.a * (1.0 - uTransparency) * uSurfOpacity; if (a < 0.01) discard; fragColor = vec4(rgb, a); } diff --git a/src/AcDream.App/Rendering/Shaders/sky.vert b/src/AcDream.App/Rendering/Shaders/sky.vert index 1a2427f..0d6b4f1 100644 --- a/src/AcDream.App/Rendering/Shaders/sky.vert +++ b/src/AcDream.App/Rendering/Shaders/sky.vert @@ -47,6 +47,7 @@ uniform vec3 uSunDir; // unit vector FROM surface TO sun // Per-submesh (from Surface.Luminosity float): uniform float uEmissive; +uniform float uDiffuseFactor; // Shared SceneLighting UBO — we need uFogParams.xy (fog start/end) to // compute the vertex fog factor. Must match sky.frag's declaration. @@ -87,7 +88,7 @@ void main() { float diff = max(dot(worldNormal, uSunDir), 0.0); vec3 lit = vec3(uEmissive) // material.Emissive + uAmbientColor // material.Ambient(1) × light.Ambient - + uSunColor * diff; // material.Diffuse(1) × light.Diffuse × N·L + + (uSunColor * uDiffuseFactor) * diff; vTint = clamp(lit, 0.0, 1.0); // Retail vertex-fog in 3D-range mode (FOGVERTEXMODE=LINEAR, diff --git a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs index c593950..84969e8 100644 --- a/src/AcDream.App/Rendering/Sky/SkyRenderer.cs +++ b/src/AcDream.App/Rendering/Sky/SkyRenderer.cs @@ -106,8 +106,10 @@ public sealed unsafe class SkyRenderer : IDisposable Vector3 cameraWorldPos, float dayFraction, DayGroupData? group, - SkyKeyframe keyframe) - => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: false); + SkyKeyframe keyframe, + bool environOverrideActive = false) + => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, + postScenePass: false, environOverrideActive: environOverrideActive); /// /// Draw the POST-SCENE sky objects (the foreground rain mesh @@ -134,8 +136,10 @@ public sealed unsafe class SkyRenderer : IDisposable Vector3 cameraWorldPos, float dayFraction, DayGroupData? group, - SkyKeyframe keyframe) - => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, postScenePass: true); + SkyKeyframe keyframe, + bool environOverrideActive = false) + => RenderPass(camera, cameraWorldPos, dayFraction, group, keyframe, + postScenePass: true, environOverrideActive: environOverrideActive); /// /// Shared pass for and . @@ -151,7 +155,8 @@ public sealed unsafe class SkyRenderer : IDisposable float dayFraction, DayGroupData? group, SkyKeyframe keyframe, - bool postScenePass) + bool postScenePass, + bool environOverrideActive) { if (group is null || group.SkyObjects.Count == 0) return; @@ -209,6 +214,12 @@ public sealed unsafe class SkyRenderer : IDisposable float secondsSinceStart = (float)(DateTime.UtcNow - _startedAt).TotalSeconds; + // M1: track texture handles whose wrap mode we set to ClampToEdge + // so we can restore them to Repeat (TextureCache's default upload + // state) at end-of-pass. Without this, any subsequent renderer + // sharing the texture handle would silently inherit ClampToEdge. + var clampedTextures = new HashSet(); + for (int i = 0; i < group.SkyObjects.Count; i++) { var obj = group.SkyObjects[i]; @@ -227,6 +238,11 @@ public sealed unsafe class SkyRenderer : IDisposable // foreground rain — double-thick rain not matching retail. if (obj.IsPostScene != postScenePass) continue; if (!obj.IsVisible(dayFraction)) continue; + // Retail GameSky::Draw (0x00506ff0) skips Properties bit 0x02 + // objects while an AdminEnvirons fog override is active. Normal + // DayGroup fog/tint still draws them. + if (environOverrideActive && (obj.Properties & 0x02u) != 0u) + continue; // Apply per-keyframe replace overrides. uint gfxObjId = obj.GfxObjId; @@ -243,20 +259,18 @@ public sealed unsafe class SkyRenderer : IDisposable // NO Dereth sky surface carries the SurfaceType.Luminous flag // bit (0x40) — the differentiator is purely the float field. float replaceLuminosity = float.NaN; + float replaceDiffuse = float.NaN; if (replaces.TryGetValue((uint)i, out var rep)) { if (rep.GfxObjId != 0) gfxObjId = rep.GfxObjId; if (rep.Rotate != 0f) headingDeg = rep.Rotate; transparent = Math.Clamp(rep.Transparent, 0f, 1f); if (rep.Luminosity > 0f) replaceLuminosity = rep.Luminosity; - // MaxBright is a CAP: even if the surface authored Lum=1.0, - // a per-keyframe MaxBright trims it. When no explicit - // Luminosity replace exists, MaxBright still acts as the - // ceiling (applied against sub.SurfLuminosity at draw time). + // Retail GameSky::UseTime routes max_bright through + // CPhysicsObj::SetDiffusion, so it replaces material diffuse, + // not emissive/luminosity. if (rep.MaxBright > 0f) - replaceLuminosity = float.IsNaN(replaceLuminosity) - ? rep.MaxBright - : MathF.Min(replaceLuminosity, rep.MaxBright); + replaceDiffuse = rep.MaxBright; } if (gfxObjId == 0) continue; @@ -277,18 +291,24 @@ public sealed unsafe class SkyRenderer : IDisposable // if (((eax_13 & 4) != 0 && (eax_13 & 8) == 0)) // int32_t var_4_1 = 0xc2f00000; // 0xc2f00000 == -120.0f // - // Weather objects (property bit 0x04 set, bit 0x08 unset) - // have their frame origin set to player_pos + (0, 0, -120m). - // The rain cylinder GfxObjs 0x01004C42/0x01004C44 have local - // Z range 0.11..814.90 (815m tall, 113m radius). Without the - // offset the cylinder bottom sits at z=0.11 ABOVE the camera - // (skyView translation is zeroed so model-origin == camera); - // looking horizontally shows nothing, looking up shows a - // distant cylinder. With -120m the cylinder spans z = - // (camera-119.89)..(camera+694.90) in view space — camera - // is inside, looking in any direction shows surrounding - // walls — the volumetric foreground-rain look retail has. - if (postScenePass) + // Gate: bit 0x04 (weather) set AND bit 0x08 unset. NOT every + // post-scene SkyObject — bit 0x01 (post-scene) is independent + // of bit 0x04 (weather). Today's Dereth ships every post-scene + // entry as also weather-flagged so the previous unconditional + // offset was a no-op divergence, but a future DayGroup with a + // post-scene-but-not-weather entry (e.g. a foreground sun rim) + // would have been pushed 120m below the camera and rendered as + // floor lint. + // + // Without the offset on the rain cylinder GfxObjs + // 0x01004C42/0x01004C44 (local Z range 0.11..814.90) the + // cylinder bottom sits at z=0.11 ABOVE the camera (skyView + // translation is zeroed so model-origin == camera); looking + // horizontally shows nothing. With -120m the cylinder spans z + // = (camera-119.89)..(camera+694.90) — camera is inside, + // looking in any direction shows surrounding walls — the + // volumetric foreground-rain look retail has. + if (postScenePass && obj.IsWeather && (obj.Properties & 0x08u) == 0u) model = model * Matrix4x4.CreateTranslation(0f, 0f, -120f); _shader.SetMatrix4("uModel", model); @@ -343,20 +363,17 @@ public sealed unsafe class SkyRenderer : IDisposable float effEmissive = float.IsNaN(replaceLuminosity) ? sub.SurfLuminosity : replaceLuminosity; + float effDiffuse = float.IsNaN(replaceDiffuse) + ? sub.SurfDiffuse + : replaceDiffuse; _shader.SetFloat("uEmissive", effEmissive); + _shader.SetFloat("uDiffuseFactor", effDiffuse); - // Retail per-Surface translucency override (D3DPolyRender::SetSurface - // at 0x59c7a6, decomp 425255-425260): when the Surface's - // Translucent (0x10) bit is set, retail computes - // curr_alpha = _ftol2(translucency × 255) and writes it as vertex - // alpha — i.e. the dat's Translucency float is the OPACITY - // directly, NOT inverted. ACViewer and WorldBuilder both invert - // it (1 - x) and are wrong by the same misread. The shader uses - // it directly as an opacity multiplier; for non-Translucent - // surfaces the GfxObjMesh.Build() path keeps SurfTranslucency=1.0 - // (no effect). Critical for rain (Translucency=0.5 → opacity 0.5) - // and clouds (Translucency=0.25 → opacity 0.25, dim like retail). - _shader.SetFloat("uSurfTranslucency", sub.SurfTranslucency); + // Material alpha is final opacity: 1 - Surface.Translucency + // for Translucent surfaces, 1 for non-Translucent surfaces. + // The CPU computes it once so the shader just multiplies it + // with texture alpha and keyframe transparency. + _shader.SetFloat("uSurfOpacity", sub.SurfOpacity); // Retail D3DPolyRender::SetSurface at 0x59c882 calls // SetFFFogAlphaDisabled(1) when the Additive flag (0x10000) @@ -364,9 +381,12 @@ public sealed unsafe class SkyRenderer : IDisposable // additive cloud sheet are drawn WITHOUT fog. Skipping fog // on additive surfaces keeps the sun bright at horizon // dusk/dawn (where fog would otherwise dim it to fog color). - // Non-additive sky meshes (the dome, opaque cloud layers) - // still mix toward fog with the floor mitigation in sky.frag. - _shader.SetFloat("uApplyFog", sub.IsAdditive ? 0f : 1f); + // Non-additive sky meshes (the dome/background layers) + // still mix toward keyframe fog with the floor mitigation + // in sky.frag. That restores the broad green/purple Rainy + // DayGroup tint behind the cloud sheet while raw-additive + // 0x08000023 remains unfogged and keeps the pink detail. + _shader.SetFloat("uApplyFog", sub.DisableFog ? 0f : 1f); uint tex = _textures.GetOrUpload(sub.SurfaceId); _gl.ActiveTexture(TextureUnit.Texture0); @@ -396,11 +416,25 @@ public sealed unsafe class SkyRenderer : IDisposable bool needsRepeat = sub.NeedsUvRepeat || obj.TexVelocityX != 0f || obj.TexVelocityY != 0f; - int wrapMode = needsRepeat - ? (int)TextureWrapMode.Repeat - : (int)TextureWrapMode.ClampToEdge; - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, wrapMode); - _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, wrapMode); + if (!needsRepeat) + { + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, + (int)TextureWrapMode.ClampToEdge); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, + (int)TextureWrapMode.ClampToEdge); + clampedTextures.Add(tex); + } + // No else branch: TextureCache uploads with Repeat, so a + // texture whose wrap was clamped earlier this pass and is + // re-bound now still needs to be told to Repeat. + else if (clampedTextures.Contains(tex)) + { + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, + (int)TextureWrapMode.Repeat); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, + (int)TextureWrapMode.Repeat); + clampedTextures.Remove(tex); + } _gl.BindVertexArray(sub.Vao); _gl.DrawElements(PrimitiveType.Triangles, @@ -410,6 +444,18 @@ public sealed unsafe class SkyRenderer : IDisposable } } + // M1: restore wrap mode on every texture this pass clamped, so + // the rest of the pipeline sees TextureCache's default Repeat + // state regardless of which sky-mesh order we drew. + foreach (var tex in clampedTextures) + { + _gl.BindTexture(TextureTarget.Texture2D, tex); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS, + (int)TextureWrapMode.Repeat); + _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT, + (int)TextureWrapMode.Repeat); + } + // Restore GL state expected by the rest of the pipeline. _gl.Disable(EnableCap.Blend); _gl.DepthMask(true); @@ -639,7 +685,7 @@ public sealed unsafe class SkyRenderer : IDisposable Console.WriteLine( $"[sky-dump] Surface[{i}] 0x{surfaceId:X8} Type=0x{rawType:X8} ({names}) " + $"OrigTexture=0x{origTex:X8} Translucency={trans} " + - $"SurfLuminosity={surface.Luminosity:F4} SurfTranslucency={surface.Translucency:F4}"); + $"SurfLuminosity={surface.Luminosity:F4} SurfaceTranslucency={surface.Translucency:F4}"); } } @@ -692,8 +738,10 @@ public sealed unsafe class SkyRenderer : IDisposable SurfaceId = sm.SurfaceId, IsAdditive = isAdditive, SurfLuminosity = sm.Luminosity, + SurfDiffuse = sm.Diffuse, NeedsUvRepeat = sm.NeedsUvRepeat, - SurfTranslucency = sm.SurfTranslucency, + SurfOpacity = sm.SurfOpacity, + DisableFog = sm.DisableFog, }; } @@ -733,6 +781,7 @@ public sealed unsafe class SkyRenderer : IDisposable /// docs/research/2026-04-23-sky-retail-verbatim.md §6. /// public float SurfLuminosity; + public float SurfDiffuse; /// /// True when the source mesh's authored UVs exceed [0,1] (e.g. /// the inner sky/star layer 0x010015EF and the cloud meshes — @@ -744,17 +793,11 @@ public sealed unsafe class SkyRenderer : IDisposable /// public bool NeedsUvRepeat; /// - /// Surface.Translucency float (0..1) carried through from - /// . Passed to the - /// sky fragment shader as uSurfTranslucency and used - /// DIRECTLY as opacity (NOT 1 - x). Retail's - /// D3DPolyRender::SetSurface at 0x59c7a6 - /// (decomp lines 425255-425260) computes - /// curr_alpha = _ftol2(translucency × 255) and writes that - /// as vertex.color.alpha — i.e. translucency is opacity directly. - /// For non-Translucent surfaces the GfxObjMesh.Build() path keeps - /// this at 1.0 so they stay fully opaque. + /// Final surface opacity from . + /// Translucent surfaces use 1 - Surface.Translucency; other + /// surfaces stay at 1.0. /// - public float SurfTranslucency; + public float SurfOpacity; + public bool DisableFog; } } diff --git a/src/AcDream.App/Rendering/TextureCache.cs b/src/AcDream.App/Rendering/TextureCache.cs index e59a255..077a12c 100644 --- a/src/AcDream.App/Rendering/TextureCache.cs +++ b/src/AcDream.App/Rendering/TextureCache.cs @@ -178,8 +178,9 @@ public sealed unsafe class TextureCache : IDisposable if (surfaceTexture is null || surfaceTexture.Textures.Count == 0) return DecodedTexture.Magenta; - var rs = _dats.Get((uint)surfaceTexture.Textures[0]); - if (rs is null) + uint renderSurfaceId = (uint)surfaceTexture.Textures[0]; + if (!_dats.Portal.TryGet(renderSurfaceId, out var rs) + && !_dats.HighRes.TryGet(renderSurfaceId, out rs)) return DecodedTexture.Magenta; // Start with the texture's default palette, then apply overlays. diff --git a/src/AcDream.Core/Meshing/GfxObjMesh.cs b/src/AcDream.Core/Meshing/GfxObjMesh.cs index 47f4368..24ed7a5 100644 --- a/src/AcDream.Core/Meshing/GfxObjMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjMesh.cs @@ -200,21 +200,14 @@ public static class GfxObjMesh // docs/research/2026-04-23-sky-retail-verbatim.md §6). var translucency = TranslucencyKind.Opaque; var luminosity = 0f; - // SurfTranslucency = the OPACITY multiplier the shader applies - // to fragment alpha. 1.0 = fully opaque (default, non-Translucent - // surfaces). For Translucent-flag surfaces, retail's - // D3DPolyRender::SetSurface at 0x59c7a6 (decomp lines 425255- - // 425260) computes curr_alpha = _ftol2(translucency × 255) and - // feeds that as vertex.color.alpha — so the dat's Translucency - // float is the OPACITY directly (NOT inverted). For rain - // (translucency=0.5) opacity is 0.5; for cloud surface - // 0x08000023 (translucency=0.25) opacity is 0.25 — that's why - // retail's clouds are dim and acdream's were 3× too bright - // before this fix (we used 1-translucency, inverting the - // semantic). ACViewer's TextureCache.cs:142 and WorldBuilder's - // ObjectMeshManager.cs:1115 also use 1-translucency and are - // both wrong by the same misread. - var surfTranslucency = 1.0f; + // SurfOpacity = (1 - Surface.Translucency) for Translucent + // surfaces, 1.0 otherwise. See + // TranslucencyKindExtensions.OpacityFromSurfaceTranslucency for + // the decomp citation (CMaterial::SetTranslucencySimple at + // 0x005396f0 writes material alpha as 1 - translucency). + var diffuse = 1f; + var surfOpacity = 1f; + var disableFog = false; if (dats is not null) { var surface = dats.Get(surfaceId); @@ -222,13 +215,16 @@ public static class GfxObjMesh { translucency = TranslucencyKindExtensions.FromSurfaceType(surface.Type); luminosity = surface.Luminosity; + diffuse = surface.Diffuse; // Apply the dat's Translucency value as opacity ONLY // when the Translucent flag (0x10) is set on the // Surface. Without this gate, surfaces with // Translucency=0 (non-Translucent default) would // render fully transparent. - if (((uint)surface.Type & (uint)DatReaderWriter.Enums.SurfaceType.Translucent) != 0) - surfTranslucency = surface.Translucency; + surfOpacity = TranslucencyKindExtensions.OpacityFromSurfaceTranslucency( + surface.Type, + surface.Translucency); + disableFog = TranslucencyKindExtensions.DisablesFixedFunctionFog(surface.Type); } } @@ -256,8 +252,10 @@ public static class GfxObjMesh { Translucency = translucency, Luminosity = luminosity, + Diffuse = diffuse, NeedsUvRepeat = needsUvRepeat, - SurfTranslucency = surfTranslucency, + SurfOpacity = surfOpacity, + DisableFog = disableFog, }); } return result; diff --git a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs index 31542a6..6b517e7 100644 --- a/src/AcDream.Core/Meshing/GfxObjSubMesh.cs +++ b/src/AcDream.Core/Meshing/GfxObjSubMesh.cs @@ -13,67 +13,40 @@ public sealed record GfxObjSubMesh( { /// /// How this sub-mesh should be composited into the frame. - /// Populated from Surface.Type flags at upload time (requires a DatCollection). - /// Defaults to so offline fixtures - /// that don't supply dat access compile and pass unchanged. + /// Populated from Surface.Type flags at upload time. /// public TranslucencyKind Translucency { get; init; } = TranslucencyKind.Opaque; /// - /// Self-illumination strength of the Surface (Surface.Luminosity - /// field, 0..1 fraction — NOT the SurfaceType.Luminous flag bit). - /// Retail uses this as an emissive coefficient in the per-vertex - /// lighting formula: - /// - /// tint = clamp(vec3(Luminosity) + AmbColor + diffuse * DirColor, 0, 1) - /// fragment = texture * tint - /// - /// For Dereth's sky meshes, the DOME (0x010015EE) and SUN/MOON - /// (0x01001348) have Luminosity=1.0 (self-illuminated — emissive - /// saturates the lighting math so the baked texture always renders - /// at full brightness). CLOUDS (0x010015EF, 0x01004C36) have - /// Luminosity=0.0 (lit by ambient+diffuse — pick up the - /// time-of-day tint). See - /// docs/research/2026-04-23-sky-retail-verbatim.md §6. - /// Defaults to 0.0 (fully lit) so non-sky meshes render through the - /// normal lighting path without change. + /// Surface.Luminosity. Retail uses this as material emissive. /// public float Luminosity { get; init; } = 0f; /// - /// True when at least one vertex's UV component lies outside the - /// [0, 1] range, meaning the mesh was authored to have its - /// texture tile across the geometry (i.e. it expects - /// GL_REPEAT/D3DTADDRESS_WRAP). The sky renderer reads - /// this to decide between GL_REPEAT (this flag set, or any - /// scrolling layer) and GL_CLAMP_TO_EDGE (all UVs strictly - /// in [0,1]), which avoids wall-seam bleed on the dome - /// (UVs in [0,1]) while still tiling the inner star/cloud - /// layers (UVs in [~0.4, ~4.6]) correctly. - /// Defaults to false so non-sky consumers get the previous behavior. + /// Surface.Diffuse. Retail sky keyframes route SkyObjectReplace.MaxBright + /// through CPhysicsObj::SetDiffusion (0x005119e0), which lands in + /// CMaterial::SetDiffuseSimple (0x00539750). + /// + public float Diffuse { get; init; } = 1f; + + /// + /// True when at least one vertex UV component lies outside [0, 1], so + /// the mesh expects texture repeat instead of clamp. /// public bool NeedsUvRepeat { get; init; } = false; /// - /// Surface.Translucency float (0..1) treated as an OPACITY - /// multiplier on fragment alpha. 1.0 = fully opaque (default for - /// non-Translucent surfaces). Distinct from the - /// classifier above, which buckets the - /// flag bits. Retail's D3DPolyRender::SetSurface at - /// 0x59c7a6 (decomp lines 425255-425260) reads - /// Surface.Translucency when the Translucent (0x10) bit - /// is set, computes curr_alpha = _ftol2(translucency × 255), - /// and writes that as vertex alpha — i.e. the dat's Translucency float - /// is used DIRECTLY as opacity, NOT inverted. ACViewer - /// (TextureCache.cs:142) and WorldBuilder - /// (ObjectMeshManager.cs:1115) both use 1 - translucency - /// and are wrong by the same misread. - /// For the rain Surface 0x080000C5 (translucency=0.5): opacity = 0.5; - /// with the (SrcAlpha, One) additive blend the rain streaks - /// contribute at half intensity. For cloud surface 0x08000023 - /// (translucency=0.25): opacity = 0.25 (matches retail's dim clouds). - /// Defaults to 1.0 (fully opaque) so non-Translucent surfaces render - /// at full opacity without change. + /// Final opacity multiplier derived from Surface.Translucency. Retail + /// translucency is transparency: 0.0 is opaque and 1.0 is invisible. + /// CMaterial::SetTranslucencySimple at 0x005396f0 writes material alpha + /// as 1 - translucency. /// - public float SurfTranslucency { get; init; } = 1f; + public float SurfOpacity { get; init; } = 1f; + + /// + /// True when the raw Surface.Type has the Additive bit. Retail disables + /// fixed-function fog alpha for this raw bit even if the final blend mode + /// is forced to AlphaBlend by the Translucent+ClipMap branch. + /// + public bool DisableFog { get; init; } = false; } diff --git a/src/AcDream.Core/Meshing/TranslucencyKind.cs b/src/AcDream.Core/Meshing/TranslucencyKind.cs index 07aaa29..d4ab468 100644 --- a/src/AcDream.Core/Meshing/TranslucencyKind.cs +++ b/src/AcDream.Core/Meshing/TranslucencyKind.cs @@ -106,4 +106,25 @@ public static class TranslucencyKindExtensions return TranslucencyKind.Opaque; } + + /// + /// Retail translucency is transparency: 0 = opaque, 1 = invisible. + /// CMaterial::SetTranslucencySimple at 0x005396f0 writes material alpha + /// as 1 - translucency. + /// + public static float OpacityFromSurfaceTranslucency(SurfaceType type, float translucency) + { + if ((type & SurfaceType.Translucent) == 0) + return 1f; + + return Math.Clamp(1f - translucency, 0f, 1f); + } + + /// + /// D3DPolyRender::SetSurface at 0x0059c882 disables fixed-function fog + /// alpha whenever the raw Additive surface bit is present, even when the + /// Translucent+ClipMap branch later forces alpha blending. + /// + public static bool DisablesFixedFunctionFog(SurfaceType type) + => (type & SurfaceType.Additive) != 0; } diff --git a/src/AcDream.Core/Textures/SurfaceDecoder.cs b/src/AcDream.Core/Textures/SurfaceDecoder.cs index 9bb6aa6..e48b9a4 100644 --- a/src/AcDream.Core/Textures/SurfaceDecoder.cs +++ b/src/AcDream.Core/Textures/SurfaceDecoder.cs @@ -37,9 +37,9 @@ public static class SurfaceDecoder PixelFormat.PFID_R8G8B8 => DecodeR8G8B8(rs), PixelFormat.PFID_A8R8G8B8 => DecodeA8R8G8B8(rs), PixelFormat.PFID_X8R8G8B8 => DecodeX8R8G8B8(rs), - PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1), - PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2), - PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3), + PixelFormat.PFID_DXT1 => DecodeBc(rs, CompressionFormat.Bc1, isClipMap), + PixelFormat.PFID_DXT3 => DecodeBc(rs, CompressionFormat.Bc2, isClipMap), + PixelFormat.PFID_DXT5 => DecodeBc(rs, CompressionFormat.Bc3, isClipMap), PixelFormat.PFID_A8 or PixelFormat.PFID_CUSTOM_LSCAPE_ALPHA => DecodeA8(rs), PixelFormat.PFID_P8 when palette is not null => DecodeP8(rs, palette, isClipMap), PixelFormat.PFID_INDEX16 when palette is not null => DecodeIndex16(rs, palette, isClipMap), @@ -245,7 +245,7 @@ public static class SurfaceDecoder return new DecodedTexture(rgba, rs.Width, rs.Height); } - private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format) + private static DecodedTexture DecodeBc(RenderSurface rs, CompressionFormat format, bool isClipMap) { var pixels = BcDecoder.DecodeRaw(rs.SourceData, rs.Width, rs.Height, format); var rgba = new byte[rs.Width * rs.Height * 4]; @@ -256,6 +256,8 @@ public static class SurfaceDecoder rgba[s + 1] = pixels[i].g; rgba[s + 2] = pixels[i].b; rgba[s + 3] = pixels[i].a; + if (isClipMap && rgba[s + 0] == 0 && rgba[s + 1] == 0 && rgba[s + 2] == 0) + rgba[s + 3] = 0; } return new DecodedTexture(rgba, rs.Width, rs.Height); } diff --git a/src/AcDream.Core/Vfx/EmitterDescLoader.cs b/src/AcDream.Core/Vfx/EmitterDescLoader.cs index 4f247d4..8623524 100644 --- a/src/AcDream.Core/Vfx/EmitterDescLoader.cs +++ b/src/AcDream.Core/Vfx/EmitterDescLoader.cs @@ -1,73 +1,38 @@ using System; using System.Collections.Concurrent; using System.Numerics; +using DatReaderWriter; +using DatParticleEmitter = DatReaderWriter.DBObjs.ParticleEmitter; +using DatEmitterType = DatReaderWriter.Enums.EmitterType; +using DatParticleType = DatReaderWriter.Enums.ParticleType; namespace AcDream.Core.Vfx; /// -/// Resolves instances by their retail emitter -/// dat id (0x32xxxxxx range). The current build of -/// Chorizite.DatReaderWriter (v2.1.7) doesn't yet ship a -/// ParticleEmitterInfo DBObj class, so we maintain a small -/// registry of synthesized descriptors for the handful of emitters -/// acdream actually needs (portal swirl, chimney smoke, fireplace -/// flames, footstep dust, spell auras, weapon trails) and fall back to -/// a generic "puff" for unknown ids. When a future DRW release adds -/// the dat-type, this class will additionally load + cache from dats. -/// -/// -/// Field mapping once the dat-type arrives (docs/research/deepdives/ -/// r04-vfx-particles.md §1 + references/DatReaderWriter's own generated -/// ParticleEmitterInfo.generated.cs): -/// -/// -/// Birthrate1 / EmitRate (retail stores the avg -/// time between spawns, not the rate). -/// -/// -/// Lifespan ± LifespanRandLifetimeMin / LifetimeMax -/// range. -/// -/// -/// A, MinA, MaxA → primary initial velocity with magnitude -/// jitter; B / C are secondary spread components. -/// -/// -/// StartScale, FinalScale / StartTrans, FinalTrans -/// interpolate linearly over life. -/// -/// -/// +/// Resolves retail ParticleEmitterInfo dat records +/// (0x32xxxxxx) into acdream runtime descriptors. /// public sealed class EmitterDescRegistry { + private const uint FallbackEmitterId = 0xFFFFFFFFu; + + private readonly Func? _resolver; private readonly ConcurrentDictionary _byId = new(); public EmitterDescRegistry() + : this((Func?)null) { - // Seed with a handful of well-known AC emitter ids plus a - // fallback. Ids here come from empirical ACViewer dat dumps — - // see r04 §5.2 for the more complete inventory. - Register(new EmitterDesc - { - DatId = 0xFFFFFFFFu, // "default" sentinel - Type = ParticleType.LocalVelocity, - Flags = EmitterFlags.Billboard | EmitterFlags.FaceCamera, - EmitRate = 10f, - MaxParticles = 32, - LifetimeMin = 0.6f, - LifetimeMax = 1.2f, - OffsetDir = new Vector3(0, 0, 1), - MinOffset = 0f, - MaxOffset = 0.1f, - SpawnDiskRadius = 0.1f, - InitialVelocity = new Vector3(0, 0, 0.5f), - VelocityJitter = 0.3f, - StartSize = 0.25f, - EndSize = 0.6f, - StartAlpha = 0.85f, - EndAlpha = 0f, - }); + } + + public EmitterDescRegistry(DatCollection dats) + : this(id => SafeGet(dats, id)) + { + } + + public EmitterDescRegistry(Func? resolver) + { + _resolver = resolver; + Register(BuildFallback()); } public void Register(EmitterDesc desc) @@ -78,10 +43,159 @@ public sealed class EmitterDescRegistry public EmitterDesc Get(uint emitterId) { - if (_byId.TryGetValue(emitterId, out var desc)) return desc; - if (_byId.TryGetValue(0xFFFFFFFFu, out var fallback)) return fallback; + if (_byId.TryGetValue(emitterId, out var desc)) + return desc; + + if (_resolver is not null) + { + var dat = _resolver(emitterId); + if (dat is not null) + { + desc = FromDat(emitterId, dat); + _byId[emitterId] = desc; + return desc; + } + } + + if (_byId.TryGetValue(FallbackEmitterId, out var fallback)) + return fallback; + throw new InvalidOperationException("No default emitter registered in registry."); } public int Count => _byId.Count; + + public static EmitterDesc FromDat(uint emitterId, DatParticleEmitter dat) + { + ArgumentNullException.ThrowIfNull(dat); + + float birthrate = MathF.Max(0f, (float)dat.Birthrate); + float lifespan = MathF.Max(0f, (float)dat.Lifespan); + float lifespanRand = MathF.Abs((float)dat.LifespanRand); + float lifetimeMin = MathF.Max(0f, lifespan - lifespanRand); + float lifetimeMax = MathF.Max(lifetimeMin, lifespan + lifespanRand); + + // ParticleEmitterInfo has no "additive" field; retail derives blend + // state from the particle GfxObj surface material. + var flags = EmitterFlags.Billboard | EmitterFlags.FaceCamera; + if (dat.IsParentLocal) + flags |= EmitterFlags.AttachLocal; + + // ParticleEmitterInfo stores translucency, not opacity. Retail feeds + // StartTrans/FinalTrans to PhysicsPart::SetTranslucency; the GL path + // uses the complement as source alpha. + float startOpacity = 1f - Math.Clamp((float)dat.StartTrans, 0f, 1f); + float endOpacity = 1f - Math.Clamp((float)dat.FinalTrans, 0f, 1f); + + return new EmitterDesc + { + DatId = emitterId, + Type = MapParticleType(dat.ParticleType), + EmitterKind = MapEmitterKind(dat.EmitterType), + Flags = flags, + GfxObjId = dat.GfxObjId.DataId, + HwGfxObjId = dat.HwGfxObjId.DataId, + Birthrate = birthrate, + EmitRate = dat.EmitterType == DatEmitterType.BirthratePerSec && birthrate > 0f + ? 1f / birthrate + : 0f, + MaxParticles = Math.Max(1, dat.MaxParticles), + InitialParticles = Math.Max(0, dat.InitialParticles), + TotalParticles = Math.Max(0, dat.TotalParticles), + TotalDuration = MathF.Max(0f, (float)dat.TotalSeconds), + Lifespan = lifespan, + LifespanRand = lifespanRand, + LifetimeMin = lifetimeMin, + LifetimeMax = lifetimeMax, + OffsetDir = dat.OffsetDir, + MinOffset = dat.MinOffset, + MaxOffset = dat.MaxOffset, + SpawnDiskRadius = dat.MaxOffset, + InitialVelocity = dat.A, + Gravity = dat.B, + A = dat.A, + MinA = dat.MinA, + MaxA = dat.MaxA, + B = dat.B, + MinB = dat.MinB, + MaxB = dat.MaxB, + C = dat.C, + MinC = dat.MinC, + MaxC = dat.MaxC, + StartSize = dat.StartScale, + EndSize = dat.FinalScale, + ScaleRand = dat.ScaleRand, + StartAlpha = startOpacity, + EndAlpha = endOpacity, + TransRand = dat.TransRand, + }; + } + + private static DatParticleEmitter? SafeGet(DatCollection dats, uint id) + { + if (dats is null) + return null; + try + { + return dats.Get(id); + } + catch + { + return null; + } + } + + private static EmitterDesc BuildFallback() => new() + { + DatId = FallbackEmitterId, + Type = ParticleType.LocalVelocity, + EmitterKind = ParticleEmitterKind.BirthratePerSec, + Flags = EmitterFlags.Billboard | EmitterFlags.FaceCamera, + Birthrate = 0.1f, + EmitRate = 10f, + MaxParticles = 32, + LifetimeMin = 0.6f, + LifetimeMax = 1.2f, + Lifespan = 0.9f, + LifespanRand = 0.3f, + OffsetDir = new Vector3(0, 0, 1), + MinOffset = 0f, + MaxOffset = 0.1f, + SpawnDiskRadius = 0.1f, + InitialVelocity = new Vector3(0, 0, 0.5f), + VelocityJitter = 0.3f, + A = new Vector3(0, 0, 0.5f), + MinA = 1f, + MaxA = 1f, + B = Vector3.Zero, + C = Vector3.Zero, + StartSize = 0.25f, + EndSize = 0.6f, + StartAlpha = 0.85f, + EndAlpha = 0f, + }; + + private static ParticleEmitterKind MapEmitterKind(DatEmitterType type) => type switch + { + DatEmitterType.BirthratePerSec => ParticleEmitterKind.BirthratePerSec, + DatEmitterType.BirthratePerMeter => ParticleEmitterKind.BirthratePerMeter, + _ => ParticleEmitterKind.Unknown, + }; + + private static ParticleType MapParticleType(DatParticleType type) => type switch + { + DatParticleType.Still => ParticleType.Still, + DatParticleType.LocalVelocity => ParticleType.LocalVelocity, + DatParticleType.ParabolicLVGA => ParticleType.ParabolicLVGA, + DatParticleType.ParabolicLVGAGR => ParticleType.ParabolicLVGAGR, + DatParticleType.Swarm => ParticleType.Swarm, + DatParticleType.Explode => ParticleType.Explode, + DatParticleType.Implode => ParticleType.Implode, + DatParticleType.ParabolicLVLA => ParticleType.ParabolicLVLA, + DatParticleType.ParabolicLVLALR => ParticleType.ParabolicLVLALR, + DatParticleType.ParabolicGVGA => ParticleType.ParabolicGVGA, + DatParticleType.ParabolicGVGAGR => ParticleType.ParabolicGVGAGR, + DatParticleType.GlobalVelocity => ParticleType.GlobalVelocity, + _ => ParticleType.Unknown, + }; } diff --git a/src/AcDream.Core/Vfx/ParticleHookSink.cs b/src/AcDream.Core/Vfx/ParticleHookSink.cs index 0054c8b..bfb47e1 100644 --- a/src/AcDream.Core/Vfx/ParticleHookSink.cs +++ b/src/AcDream.Core/Vfx/ParticleHookSink.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Concurrent; using System.Numerics; +using System.Threading; using AcDream.Core.Physics; using DatReaderWriter.Types; @@ -62,10 +63,30 @@ public sealed class ParticleHookSink : IAnimationHookSink // key ("the smoke trail I spawned 2 seconds ago"), so we track by // (entity, emitterId). private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new(); + // entityId → set of live emitter handles. Dictionary-as-set so we can + // remove individual handles when their emitter dies (M4 fix — + // ConcurrentBag couldn't drop entries, so handles for naturally-expired + // emitters used to leak). + private readonly ConcurrentDictionary> _handlesByEntity = new(); + // Reverse lookup: handle → (entity, key) for O(1) cleanup on EmitterDied. + private readonly ConcurrentDictionary _trackingByHandle = new(); + private readonly ConcurrentDictionary _renderPassByEntity = new(); + private readonly ConcurrentDictionary _rotationByEntity = new(); + private int _anonymousEmitterSerial; public ParticleHookSink(ParticleSystem system) { _system = system ?? throw new ArgumentNullException(nameof(system)); + _system.EmitterDied += OnEmitterDied; + } + + private void OnEmitterDied(int handle) + { + if (!_trackingByHandle.TryRemove(handle, out var t)) + return; + _handlesByKey.TryRemove((t.EntityId, t.KeyId), out _); + if (_handlesByEntity.TryGetValue(t.EntityId, out var bag)) + bag.TryRemove(handle, out _); } public void OnHook(uint entityId, Vector3 entityWorldPosition, AnimationHook hook) @@ -104,6 +125,54 @@ public sealed class ParticleHookSink : IAnimationHookSink } } + public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass) + => _renderPassByEntity[entityId] = renderPass; + + public void SetEntityRotation(uint entityId, Quaternion rotation) + => _rotationByEntity[entityId] = rotation; + + public void ClearEntityRenderPass(uint entityId) + => _renderPassByEntity.TryRemove(entityId, out _); + + /// + /// Refresh every live emitter on this entity to a new world anchor + + /// rotation. The owning subsystem (sky-PES driver, animation tick) + /// drives this each frame for AttachLocal emitters so they track their + /// moving parent — retail-faithful via + /// ParticleEmitter::UpdateParticles at 0x0051d2d4, which + /// re-reads the parent frame each tick when is_parent_local != 0. + /// Safe to call for entities with no live emitters (no-op). + /// + public void UpdateEntityAnchor(uint entityId, Vector3 anchor, Quaternion rotation) + { + _rotationByEntity[entityId] = rotation; + if (!_handlesByEntity.TryGetValue(entityId, out var bag)) + return; + foreach (var handle in bag.Keys) + _system.UpdateEmitterAnchor(handle, anchor, rotation); + } + + public void StopAllForEntity(uint entityId, bool fadeOut) + { + if (_handlesByEntity.TryRemove(entityId, out var handles)) + { + foreach (var handle in handles.Keys) + { + _system.StopEmitter(handle, fadeOut); + _trackingByHandle.TryRemove(handle, out _); + } + } + + foreach (var key in _handlesByKey.Keys) + { + if (key.EntityId == entityId) + _handlesByKey.TryRemove(key, out _); + } + + ClearEntityRenderPass(entityId); + _rotationByEntity.TryRemove(entityId, out _); + } + private void SpawnFromHook( uint entityId, Vector3 worldPos, @@ -115,15 +184,35 @@ public sealed class ParticleHookSink : IAnimationHookSink // Spawn position: entity pose + hook offset. PartIndex will be // used when the renderer passes per-part transforms through; for // now, fold it into the root pos. - var anchor = worldPos + offset; + var rotation = _rotationByEntity.TryGetValue(entityId, out var rot) + ? rot + : Quaternion.Identity; + var anchor = worldPos + Vector3.Transform(offset, rotation); + var renderPass = _renderPassByEntity.TryGetValue(entityId, out var pass) + ? pass + : ParticleRenderPass.Scene; int handle = _system.SpawnEmitterById( emitterId: emitterInfoId, anchor: anchor, - rot: Quaternion.Identity, + rot: rotation, attachedObjectId: entityId, - attachedPartIndex: partIndex); + attachedPartIndex: partIndex, + renderPass: renderPass); - _handlesByKey[(entityId, logicalId)] = handle; + uint keyId = logicalId != 0 + ? logicalId + : 0x80000000u | (uint)Interlocked.Increment(ref _anonymousEmitterSerial); + if (logicalId != 0 && _handlesByKey.TryRemove((entityId, keyId), out var oldHandle)) + { + _system.StopEmitter(oldHandle, fadeOut: false); + _trackingByHandle.TryRemove(oldHandle, out _); + } + + _handlesByKey[(entityId, keyId)] = handle; + _handlesByEntity + .GetOrAdd(entityId, _ => new ConcurrentDictionary()) + .TryAdd(handle, 0); + _trackingByHandle[handle] = (entityId, keyId); } } diff --git a/src/AcDream.Core/Vfx/ParticleSystem.cs b/src/AcDream.Core/Vfx/ParticleSystem.cs index 1c85b5a..53c5d70 100644 --- a/src/AcDream.Core/Vfx/ParticleSystem.cs +++ b/src/AcDream.Core/Vfx/ParticleSystem.cs @@ -5,33 +5,18 @@ using System.Numerics; namespace AcDream.Core.Vfx; /// -/// Runtime particle orchestrator — port of retail's CParticleManager -/// (r04 §2). Owns a pool of active instances, -/// advances each per-frame via one of 13 motion integrators, fades colour / -/// scale over life, and exposes a flat particle stream for the renderer. -/// -/// -/// Not thread-safe — called only from the render thread (same thread that -/// drives TickAnimations). -/// -/// -/// -/// Handle-based API so callers can stop a specific emitter later (cast -/// interrupt, fadeout). returns a positive -/// integer; accepts it. -/// +/// Runtime particle orchestrator. The data and update rules are a direct +/// port of retail's ParticleEmitterInfo, ParticleEmitter, and +/// Particle::Update paths from the named retail decompilation. /// public sealed class ParticleSystem : IParticleSystem { private readonly EmitterDescRegistry _registry; private readonly Random _rng; - - // All live emitters keyed by our handle. Lookup is cheap; iteration is - // per-frame so we also keep a flat list for stable ordering (draw order). private readonly Dictionary _byHandle = new(); private readonly List _handleOrder = new(); - private int _nextHandle = 1; + private int _nextHandle = 1; private float _time; private int _activeParticleCount; @@ -49,7 +34,8 @@ public sealed class ParticleSystem : IParticleSystem Vector3 anchor, Quaternion? rot = null, uint attachedObjectId = 0, - int attachedPartIndex = -1) + int attachedPartIndex = -1, + ParticleRenderPass renderPass = ParticleRenderPass.Scene) { ArgumentNullException.ThrowIfNull(desc); @@ -61,43 +47,45 @@ public sealed class ParticleSystem : IParticleSystem AnchorRot = rot ?? Quaternion.Identity, AttachedObjectId = attachedObjectId, AttachedPartIndex = attachedPartIndex, + RenderPass = renderPass, Particles = new Particle[Math.Max(1, desc.MaxParticles)], StartedAt = _time, + LastEmitTime = _time, + LastEmitOffset = anchor, }; + _byHandle[handle] = emitter; _handleOrder.Add(handle); + + for (int i = 0; i < desc.InitialParticles; i++) + SpawnOne(emitter, allowWhenFull: false); + return handle; } - /// - /// Convenience: spawn by retail emitter id — the registry resolves to - /// the correct , or falls back to the default - /// if unknown. Used by the hook sink when a CreateParticleHook arrives. - /// public int SpawnEmitterById( uint emitterId, Vector3 anchor, Quaternion? rot = null, uint attachedObjectId = 0, - int attachedPartIndex = -1) + int attachedPartIndex = -1, + ParticleRenderPass renderPass = ParticleRenderPass.Scene) { var desc = _registry.Get(emitterId); - return SpawnEmitter(desc, anchor, rot, attachedObjectId, attachedPartIndex); + return SpawnEmitter(desc, anchor, rot, attachedObjectId, attachedPartIndex, renderPass); } public void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f) { - // Full PhysicsScript dispatch is on hold until the DatReaderWriter - // library exposes ParticleEmitterInfo / PhysicsScript. For now, - // this is a no-op — callers use SpawnEmitter or the hook sink. + // Full PhysicsScript scheduling lives in PhysicsScriptRunner. } public void StopEmitter(int handle, bool fadeOut) { - if (!_byHandle.TryGetValue(handle, out var em)) return; + if (!_byHandle.TryGetValue(handle, out var em)) + return; + em.Finished = true; - // fadeOut=false would stop instantly; our renderer currently drops - // Finished emitters that have no living particles each tick. if (!fadeOut) { for (int i = 0; i < em.Particles.Length; i++) @@ -105,259 +93,454 @@ public sealed class ParticleSystem : IParticleSystem } } + /// + /// Refresh an active emitter's world anchor + orientation. Required for + /// retail's is_parent_local=1 (acdream's + /// ) semantics: retail + /// ParticleEmitter::UpdateParticles at 0x0051d2d4 reads the + /// LIVE parent frame each tick when is_parent_local != 0. The + /// caller (typically a tick loop tracking a moving parent — the camera + /// for sky-PES, an entity for animation hooks) drives this every frame. + /// + public void UpdateEmitterAnchor(int handle, Vector3 anchor, Quaternion? rot = null) + { + if (!_byHandle.TryGetValue(handle, out var em)) + return; + em.AnchorPos = anchor; + if (rot.HasValue) + em.AnchorRot = rot.Value; + } + + /// True when the given handle still maps to a live emitter. + public bool IsEmitterAlive(int handle) => _byHandle.ContainsKey(handle); + + /// + /// Fired exactly once per emitter when it is removed from the live set + /// (either because it finished naturally or was stopped without fade). + /// Subscribers (e.g. ) use this to prune + /// per-entity handle tracking so the per-entity bag doesn't grow without + /// bound during a long session. + /// + public event Action? EmitterDied; + public void Tick(float dt) { - if (dt <= 0f) return; + if (dt <= 0f) + return; + _time += dt; _activeParticleCount = 0; - // Iterate handles by a snapshot so StopEmitter-inside-emit is safe. for (int i = 0; i < _handleOrder.Count; i++) { int handle = _handleOrder[i]; - if (!_byHandle.TryGetValue(handle, out var em)) continue; + if (!_byHandle.TryGetValue(handle, out var em)) + continue; - AdvanceEmitter(em, dt); - _activeParticleCount += CountAlive(em); + AdvanceEmitter(em); + int live = CountAlive(em); + em.ActiveCount = live; + _activeParticleCount += live; - bool durationDone = em.Desc.TotalDuration > 0f - && (_time - em.StartedAt) > em.Desc.TotalDuration; - if (durationDone) em.Finished = true; + if (em.Desc.TotalDuration > 0f && (_time - em.StartedAt) > em.Desc.TotalDuration) + em.Finished = true; - // Drop emitter entirely when it has no live particles AND is - // marked finished (duration elapsed, StopEmitter, etc). - if (em.Finished && CountAlive(em) == 0) + if (em.Desc.TotalParticles > 0 && em.TotalEmitted >= em.Desc.TotalParticles) + em.Finished = true; + + if (em.Finished && live == 0) { _byHandle.Remove(handle); _handleOrder.RemoveAt(i); i--; + EmitterDied?.Invoke(handle); } } } - /// - /// Enumerate every live particle with its emitter description for - /// the renderer. Yields (emitter, particleIndex) so the caller can - /// read em.Particles[idx] directly. - /// public IEnumerable<(ParticleEmitter Emitter, int Index)> EnumerateLive() { foreach (var handle in _handleOrder) { - if (!_byHandle.TryGetValue(handle, out var em)) continue; + if (!_byHandle.TryGetValue(handle, out var em)) + continue; + for (int i = 0; i < em.Particles.Length; i++) { - if (em.Particles[i].Alive) yield return (em, i); + if (em.Particles[i].Alive) + yield return (em, i); } } } - // ── Private: emission + integration ────────────────────────────────────── - - private void AdvanceEmitter(ParticleEmitter em, float dt) + private void AdvanceEmitter(ParticleEmitter em) { - if (!em.Finished && em.Desc.EmitRate > 0f) - { - em.EmittedAccumulator += dt * em.Desc.EmitRate; - while (em.EmittedAccumulator >= 1.0f) - { - em.EmittedAccumulator -= 1.0f; - SpawnOne(em); - } - } - - // Update every particle slot. for (int i = 0; i < em.Particles.Length; i++) { ref var p = ref em.Particles[i]; - if (!p.Alive) continue; + if (!p.Alive) + continue; - p.Age += dt; - if (p.Age >= p.Lifetime) + p.Age = _time - p.SpawnedAt; + if (p.Lifetime <= 0f || p.Age >= p.Lifetime) { p.Alive = false; continue; } - Integrate(ref p, em, dt); - + p.Position = ComputePosition(em, p); float tLife = Math.Clamp(p.Age / p.Lifetime, 0f, 1f); - p.Size = Lerp(em.Desc.StartSize, em.Desc.EndSize, tLife); - float alpha = Lerp(em.Desc.StartAlpha, em.Desc.EndAlpha, tLife); + p.Size = Lerp(p.StartSize, p.EndSize, tLife); + p.Rotation = Lerp(em.Desc.StartRotation, em.Desc.EndRotation, tLife); + float alpha = Lerp(p.StartAlpha, p.EndAlpha, tLife); p.ColorArgb = Color32(alpha, em.Desc.StartColorArgb, em.Desc.EndColorArgb, tLife); } + + if (em.Finished || _time < em.StartedAt + em.Desc.StartDelay) + return; + + while (ShouldEmitParticle(em)) + { + if (!SpawnOne(em, allowWhenFull: false)) + break; + } + + if (em.Desc.Birthrate <= 0f && em.Desc.EmitRate > 0f) + { + float dt = _time - em.LastEmitTime; + em.EmittedAccumulator += dt * em.Desc.EmitRate; + em.LastEmitTime = _time; + while (em.EmittedAccumulator >= 1f) + { + em.EmittedAccumulator -= 1f; + if (!SpawnOne(em, allowWhenFull: false)) + break; + } + } } - private void SpawnOne(ParticleEmitter em) + private bool ShouldEmitParticle(ParticleEmitter em) { - // Find a free slot; overwrite the oldest if pool is full. - int slot = -1; - for (int i = 0; i < em.Particles.Length; i++) + var desc = em.Desc; + if (desc.TotalParticles > 0 && em.TotalEmitted >= desc.TotalParticles) + return false; + + if (CountAlive(em) >= desc.MaxParticles) + return false; + + if (desc.Birthrate <= 0f) + return false; + + return desc.EmitterKind switch { - if (!em.Particles[i].Alive) { slot = i; break; } - } + ParticleEmitterKind.BirthratePerSec => (_time - em.LastEmitTime) > desc.Birthrate, + ParticleEmitterKind.BirthratePerMeter => + Vector3.DistanceSquared(em.AnchorPos, em.LastEmitOffset) > desc.Birthrate * desc.Birthrate, + _ => false, + }; + } + + private bool SpawnOne(ParticleEmitter em, bool allowWhenFull) + { + int slot = FindFreeSlot(em); + if (slot < 0 && allowWhenFull) + slot = FindOldestSlot(em); if (slot < 0) - { - // Pool saturated; overwrite the slot closest to dying (oldest - // by age / lifetime ratio). Matches retail's behaviour of - // recycling the expiring particle rather than dropping. - float best = -1f; - for (int i = 0; i < em.Particles.Length; i++) - { - ref var p = ref em.Particles[i]; - float r = p.Lifetime > 0 ? p.Age / p.Lifetime : 1f; - if (r > best) { best = r; slot = i; } - } - if (slot < 0) return; - } + return false; ref var particle = ref em.Particles[slot]; + particle = default; particle.Alive = true; - particle.Age = 0f; - particle.Lifetime = Lerp(em.Desc.LifetimeMin, em.Desc.LifetimeMax, - (float)_rng.NextDouble()); - - // Position = emitter anchor + random offset in a disk perpendicular - // to OffsetDir. This models the retail annulus. - Vector3 disk = RandomDiskVector(em.Desc.OffsetDir, em.Desc.MaxOffset); - particle.Position = em.AnchorPos + disk; particle.SpawnedAt = _time; + particle.Lifetime = RandomLifespan(em.Desc); + particle.EmissionOrigin = em.AnchorPos; + particle.SpawnRotation = em.AnchorRot; - // Velocity = initial vector ± jitter in all three axes. - Vector3 v = em.Desc.InitialVelocity; - if (em.Desc.VelocityJitter > 0f) + Vector3 localOffset = RandomOffset(em.Desc); + Vector3 localA = RandomVector(em.Desc.A, em.Desc.MinA, em.Desc.MaxA); + Vector3 localB = RandomVector(em.Desc.B, em.Desc.MinB, em.Desc.MaxB); + Vector3 localC = RandomVector(em.Desc.C, em.Desc.MinC, em.Desc.MaxC); + + if (localA == Vector3.Zero && em.Desc.InitialVelocity != Vector3.Zero) { - v += new Vector3( - RandomCentered(em.Desc.VelocityJitter), - RandomCentered(em.Desc.VelocityJitter), - RandomCentered(em.Desc.VelocityJitter)); + localA = em.Desc.InitialVelocity; + if (em.Desc.VelocityJitter > 0f) + { + localA += new Vector3( + RandomCentered(em.Desc.VelocityJitter), + RandomCentered(em.Desc.VelocityJitter), + RandomCentered(em.Desc.VelocityJitter)); + } } - particle.Velocity = v; - particle.Size = em.Desc.StartSize; - particle.Rotation = em.Desc.StartRotation; - particle.ColorArgb = em.Desc.StartColorArgb; + if (localB == Vector3.Zero && em.Desc.Gravity != Vector3.Zero) + localB = em.Desc.Gravity; + + InitParticleVectors(em, ref particle, localOffset, localA, localB, localC); + + particle.Velocity = particle.A; + particle.StartSize = RandomScale(em.Desc.StartSize, em.Desc.ScaleRand); + particle.EndSize = RandomScale(em.Desc.EndSize, em.Desc.ScaleRand); + particle.StartAlpha = RandomTrans(em.Desc.StartAlpha, em.Desc.TransRand); + particle.EndAlpha = RandomTrans(em.Desc.EndAlpha, em.Desc.TransRand); + particle.Size = particle.StartSize; + particle.ColorArgb = Color32(particle.StartAlpha, em.Desc.StartColorArgb, em.Desc.EndColorArgb, 0f); + particle.Position = ComputePosition(em, particle); + + em.TotalEmitted++; + em.LastEmitTime = _time; + em.LastEmitOffset = em.AnchorPos; + return true; } - // ── 13 retail motion integrators (r04 §3) ──────────────────────────────── - - private void Integrate(ref Particle p, ParticleEmitter em, float dt) + private Vector3 ComputePosition(ParticleEmitter em, Particle p) { + float t = p.Age; + Vector3 origin = (em.Desc.Flags & EmitterFlags.AttachLocal) != 0 + ? em.AnchorPos + : p.EmissionOrigin; + Vector3 offset = p.Offset; + Vector3 a = p.A; + Vector3 b = p.B; + Vector3 c = p.C; + + return em.Desc.Type switch + { + ParticleType.Still => origin + offset, + ParticleType.LocalVelocity or ParticleType.GlobalVelocity => + origin + offset + t * a, + ParticleType.ParabolicLVGA or ParticleType.ParabolicLVLA or ParticleType.ParabolicGVGA => + origin + offset + t * a + 0.5f * t * t * b, + ParticleType.ParabolicLVGAGR or ParticleType.ParabolicLVLALR or ParticleType.ParabolicGVGAGR => + origin + offset + t * a + 0.5f * t * t * b, + ParticleType.Swarm => + origin + offset + t * a + new Vector3( + MathF.Cos(t * b.X) * c.X, + MathF.Sin(t * b.Y) * c.Y, + MathF.Cos(t * b.Z) * c.Z), + ParticleType.Explode => + origin + offset + new Vector3( + (t * b.X + c.X * a.X) * t, + (t * b.Y + c.Y * a.X) * t, + (t * b.Z + c.Z * a.X + a.Z) * t), + ParticleType.Implode => + origin + offset + MathF.Cos(a.X * t) * c + t * t * b, + _ => origin + offset + t * a, + }; + } + + private void InitParticleVectors( + ParticleEmitter em, + ref Particle particle, + Vector3 localOffset, + Vector3 localA, + Vector3 localB, + Vector3 localC) + { + // Retail Particle::Init 0x0051c930 resolves local/global vector + // spaces once at spawn; Particle::Update 0x0051c290 then integrates + // those stored world-space coefficients each frame. + particle.Offset = ToSpawnWorld(em, localOffset); + particle.A = localA; + particle.B = localB; + particle.C = localC; + switch (em.Desc.Type) { - case ParticleType.Still: - // No motion. Age + fade only. - break; - case ParticleType.LocalVelocity: - // Constant spawn velocity, no acceleration. - p.Position += p.Velocity * dt; - break; - - case ParticleType.GlobalVelocity: - // Uses emitter's InitialVelocity (global/world-space); - // each particle keeps its own copy already (set at spawn), - // so behaves identically to LocalVelocity at runtime. - p.Position += p.Velocity * dt; - break; - - case ParticleType.Parabolic: - case ParticleType.ParabolicLVGV: case ParticleType.ParabolicLVGA: + particle.A = ToSpawnWorld(em, localA); + break; + case ParticleType.ParabolicLVLA: - case ParticleType.ParabolicGVGA: - case ParticleType.ParabolicGVLA: - case ParticleType.ParabolicLALV: - // Velocity decays with gravity; position integrates. - p.Velocity += em.Desc.Gravity * dt; - p.Position += p.Velocity * dt; + particle.A = ToSpawnWorld(em, localA); + particle.B = ToSpawnWorld(em, localB); + break; + + case ParticleType.ParabolicLVGAGR: + particle.A = ToSpawnWorld(em, localA); + particle.C = localC; break; case ParticleType.Swarm: - // Orbital drift around anchor. Apply a tangential swirl. - { - Vector3 toCenter = em.AnchorPos - p.Position; - Vector3 axis = em.Desc.OffsetDir == Vector3.Zero ? Vector3.UnitZ : em.Desc.OffsetDir; - Vector3 tangent = Vector3.Normalize(Vector3.Cross(axis, toCenter)); - p.Velocity = Vector3.Lerp(p.Velocity, tangent * em.Desc.InitialVelocity.Length(), dt * 4f); - p.Position += p.Velocity * dt; - } + particle.A = ToSpawnWorld(em, localA); break; case ParticleType.Explode: - // Push outward along (position - anchor). - { - Vector3 dir = p.Position - em.AnchorPos; - if (dir.LengthSquared() < 1e-6f) dir = Vector3.UnitZ; - else dir = Vector3.Normalize(dir); - p.Velocity = dir * em.Desc.InitialVelocity.Length(); - p.Position += p.Velocity * dt; - } + particle.A = localA; + particle.B = localB; + particle.C = RandomExplodeDirection(localC); break; case ParticleType.Implode: - // Pull inward toward anchor. - { - Vector3 dir = em.AnchorPos - p.Position; - float dist = dir.Length(); - if (dist < 0.01f) { p.Alive = false; break; } - dir /= dist; - p.Velocity = dir * em.Desc.InitialVelocity.Length(); - p.Position += p.Velocity * dt; - } + particle.A = localA; + particle.B = localB; + particle.Offset = new Vector3( + particle.Offset.X * localC.X, + particle.Offset.Y * localC.Y, + particle.Offset.Z * localC.Z); + particle.C = particle.Offset; break; - default: - p.Position += p.Velocity * dt; + case ParticleType.ParabolicLVLALR: + particle.A = ToSpawnWorld(em, localA); + particle.B = ToSpawnWorld(em, localB); + particle.C = ToSpawnWorld(em, localC); + break; + + case ParticleType.ParabolicGVGAGR: + particle.C = localC; break; } } - // ── Utility ────────────────────────────────────────────────────────────── + private static Vector3 ToSpawnWorld(ParticleEmitter em, Vector3 value) + => em.AnchorRot == Quaternion.Identity ? value : Vector3.Transform(value, em.AnchorRot); + + private Vector3 RandomExplodeDirection(Vector3 localC) + { + float yaw = RandomRange(-MathF.PI, MathF.PI); + float pitch = RandomRange(-MathF.PI, MathF.PI); + float cosPitch = MathF.Cos(pitch); + Vector3 c = new( + MathF.Cos(yaw) * localC.X * cosPitch, + MathF.Sin(yaw) * localC.Y * cosPitch, + MathF.Sin(pitch) * localC.Z); + + return NormalizeCheckSmall(ref c) ? Vector3.Zero : c; + } + + private int FindFreeSlot(ParticleEmitter em) + { + for (int i = 0; i < em.Particles.Length; i++) + { + if (!em.Particles[i].Alive) + return i; + } + + return -1; + } + + private static int FindOldestSlot(ParticleEmitter em) + { + int slot = -1; + float best = -1f; + for (int i = 0; i < em.Particles.Length; i++) + { + ref var p = ref em.Particles[i]; + float r = p.Lifetime > 0f ? p.Age / p.Lifetime : 1f; + if (r > best) + { + best = r; + slot = i; + } + } + + return slot; + } private static int CountAlive(ParticleEmitter em) { int n = 0; for (int i = 0; i < em.Particles.Length; i++) - if (em.Particles[i].Alive) n++; + { + if (em.Particles[i].Alive) + n++; + } + return n; } + private float RandomLifespan(EmitterDesc desc) + { + float lifespan = desc.Lifespan > 0f ? desc.Lifespan : (desc.LifetimeMin + desc.LifetimeMax) * 0.5f; + float rand = desc.LifespanRand > 0f ? desc.LifespanRand : MathF.Abs(desc.LifetimeMax - desc.LifetimeMin) * 0.5f; + float value = lifespan + RandomCentered(rand); + if (value <= 0f && desc.LifetimeMax > 0f) + value = Lerp(desc.LifetimeMin, desc.LifetimeMax, (float)_rng.NextDouble()); + return MathF.Max(0f, value); + } + + private Vector3 RandomOffset(EmitterDesc desc) + { + float min = MathF.Min(desc.MinOffset, desc.MaxOffset); + float max = MathF.Max(desc.MinOffset, desc.MaxOffset); + if (max <= 0f) + return Vector3.Zero; + + Vector3 axis = NormalizeOrZero(desc.OffsetDir); + Vector3 v = new( + RandomCentered(1f), + RandomCentered(1f), + RandomCentered(1f)); + + if (axis != Vector3.Zero) + v -= axis * Vector3.Dot(v, axis); + + if (v.LengthSquared() < 1e-8f) + v = axis != Vector3.Zero ? Perpendicular(axis) : Vector3.UnitX; + else + v = Vector3.Normalize(v); + + return v * Lerp(min, max, (float)_rng.NextDouble()); + } + + private Vector3 RandomVector(Vector3 direction, float min, float max) + { + if (direction == Vector3.Zero) + return Vector3.Zero; + + if (max < min) + (min, max) = (max, min); + + return direction * Lerp(min, max, (float)_rng.NextDouble()); + } + + private float RandomScale(float baseValue, float rand) + => Math.Clamp(baseValue + RandomCentered(rand), 0.1f, 10f); + + private float RandomTrans(float baseValue, float rand) + => Math.Clamp(baseValue + RandomCentered(rand), 0f, 1f); + + private float RandomCentered(float halfWidth) + => ((float)_rng.NextDouble() - 0.5f) * 2f * halfWidth; + + private float RandomRange(float min, float max) + => Lerp(min, max, (float)_rng.NextDouble()); + private static float Lerp(float a, float b, float t) => a + (b - a) * t; + private static Vector3 NormalizeOrZero(Vector3 v) + => v.LengthSquared() > 1e-8f ? Vector3.Normalize(v) : Vector3.Zero; + + private static bool NormalizeCheckSmall(ref Vector3 v) + { + float length = v.Length(); + if (length < 1e-8f) + return true; + + v /= length; + return false; + } + + private static Vector3 Perpendicular(Vector3 v) + { + Vector3 basis = MathF.Abs(v.X) < 0.9f ? Vector3.UnitX : Vector3.UnitY; + return Vector3.Normalize(Vector3.Cross(v, basis)); + } + private static uint Color32(float alpha, uint startArgb, uint endArgb, float t) { - // Blend RGB channels linearly; apply alpha override from fade. - byte sa = (byte)((startArgb >> 24) & 0xFF); byte sr = (byte)((startArgb >> 16) & 0xFF); - byte sg = (byte)((startArgb >> 8) & 0xFF); - byte sb = (byte)( startArgb & 0xFF); - byte ea = (byte)((endArgb >> 24) & 0xFF); + byte sg = (byte)((startArgb >> 8) & 0xFF); + byte sb = (byte)(startArgb & 0xFF); byte er = (byte)((endArgb >> 16) & 0xFF); - byte eg = (byte)((endArgb >> 8) & 0xFF); - byte eb = (byte)( endArgb & 0xFF); + byte eg = (byte)((endArgb >> 8) & 0xFF); + byte eb = (byte)(endArgb & 0xFF); + byte r = (byte)Math.Clamp(sr + (er - sr) * t, 0f, 255f); byte g = (byte)Math.Clamp(sg + (eg - sg) * t, 0f, 255f); byte b = (byte)Math.Clamp(sb + (eb - sb) * t, 0f, 255f); byte a = (byte)Math.Clamp(alpha * 255f, 0f, 255f); return ((uint)a << 24) | ((uint)r << 16) | ((uint)g << 8) | b; } - - private Vector3 RandomDiskVector(Vector3 axis, float maxRadius) - { - if (maxRadius <= 0f) return Vector3.Zero; - // Two perpendicular vectors to axis. - Vector3 n = Vector3.Normalize(axis == Vector3.Zero ? Vector3.UnitZ : axis); - Vector3 t1 = Math.Abs(n.X) < 0.9f - ? Vector3.Normalize(Vector3.Cross(n, Vector3.UnitX)) - : Vector3.Normalize(Vector3.Cross(n, Vector3.UnitY)); - Vector3 t2 = Vector3.Normalize(Vector3.Cross(n, t1)); - float theta = (float)(_rng.NextDouble() * Math.PI * 2.0); - float r = maxRadius * MathF.Sqrt((float)_rng.NextDouble()); - return (t1 * MathF.Cos(theta) + t2 * MathF.Sin(theta)) * r; - } - - private float RandomCentered(float halfWidth) - { - return ((float)_rng.NextDouble() - 0.5f) * 2f * halfWidth; - } } diff --git a/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs b/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs index f50f740..6816134 100644 --- a/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs +++ b/src/AcDream.Core/Vfx/PhysicsScriptRunner.cs @@ -139,15 +139,7 @@ public sealed class PhysicsScriptRunner _active.RemoveAt(i); } - _active.Add(new ActiveScript - { - Script = script, - ScriptId = scriptId, - EntityId = entityId, - AnchorWorld = anchorWorldPos, - StartTimeAbs = _now, - NextHookIndex = 0, - }); + AddActiveScript(script, scriptId, entityId, anchorWorldPos, delaySeconds: 0); if (DiagEnabled) { @@ -159,6 +151,24 @@ public sealed class PhysicsScriptRunner return true; } + private void AddActiveScript( + DatPhysicsScript script, + uint scriptId, + uint entityId, + Vector3 anchorWorldPos, + float delaySeconds) + { + _active.Add(new ActiveScript + { + Script = script, + ScriptId = scriptId, + EntityId = entityId, + AnchorWorld = anchorWorldPos, + StartTimeAbs = _now + Math.Max(0f, delaySeconds), + NextHookIndex = 0, + }); + } + /// /// Advance every active script by . /// Fires each hook whose @@ -233,18 +243,18 @@ public sealed class PhysicsScriptRunner if (hook is CallPESHook call) { // CallPESHook.PES = sub-script id; Pause = delay before the - // sub-script starts (retail's ScriptManager links it into - // the list with StartTime = now + Pause). For our flat-list - // design we just recurse Play() — the sub-script schedules - // its own hooks from its own time zero. If Pause > 0 we - // delay by baking it into the sub-script's StartTimeAbs. - Play(call.PES, a.EntityId, a.AnchorWorld); - if (call.Pause > 0f && _active.Count > 0) + // sub-script starts. Retail links it into the active script + // list with StartTime = now + Pause; our flat list preserves + // that timing without replacing the currently running script. + var subScript = ResolveScript(call.PES); + if (subScript is null || subScript.ScriptData.Count == 0) { - var sub = _active[^1]; - sub.StartTimeAbs = _now + call.Pause; - _active[^1] = sub; + if (DiagEnabled) + Console.WriteLine($"[pes] CallPES: script 0x{call.PES:X8} not found / empty"); + return; } + + AddActiveScript(subScript, call.PES, a.EntityId, a.AnchorWorld, call.Pause); return; } diff --git a/src/AcDream.Core/Vfx/VfxModel.cs b/src/AcDream.Core/Vfx/VfxModel.cs index 77527ad..5697431 100644 --- a/src/AcDream.Core/Vfx/VfxModel.cs +++ b/src/AcDream.Core/Vfx/VfxModel.cs @@ -4,90 +4,123 @@ using System.Numerics; namespace AcDream.Core.Vfx; -// ───────────────────────────────────────────────────────────────────── -// Scaffold for R4 — VFX / particle system data model. -// Full research: docs/research/deepdives/r04-vfx-particles.md -// Runtime GPU batching lives in AcDream.App/Rendering/Vfx (Silk.NET GL). -// ───────────────────────────────────────────────────────────────────── - /// -/// 13 retail particle motion integrators. See r04 §1. -/// Parabolic variants apply gravity with different orientation/decay rules. +/// Retail particle motion integrators from ParticleType in +/// acclient.h. Values are the retail dat values. /// public enum ParticleType { - Still = 0, // static, fades out in place - LocalVelocity = 1, // moves at its spawn velocity - Parabolic = 2, // gravity arc - ParabolicLVGV = 3, // local+global velocity parabolic - ParabolicLVGA = 4, - ParabolicLVLA = 5, - ParabolicGVGA = 6, - ParabolicGVLA = 7, - ParabolicLALV = 8, - Swarm = 9, // orbits spawn point with randomness - Explode = 10, // all particles push outward - Implode = 11, // all particles pull inward - GlobalVelocity = 12, + Unknown = 0, + Still = 1, + LocalVelocity = 2, + ParabolicLVGA = 3, + ParabolicLVGAGR = 4, + Swarm = 5, + Explode = 6, + Implode = 7, + ParabolicLVLA = 8, + ParabolicLVLALR = 9, + ParabolicGVGA = 10, + ParabolicGVGAGR = 11, + GlobalVelocity = 12, + NumParticleType = 13, +} + +/// +/// Retail EmitterType from acclient.h. +/// +public enum ParticleEmitterKind +{ + Unknown = 0, + BirthratePerSec = 1, + BirthratePerMeter = 2, +} + +/// +/// Render stage for an active particle emitter. +/// +public enum ParticleRenderPass +{ + Scene = 0, + SkyPreScene = 1, + SkyPostScene = 2, } [Flags] public enum EmitterFlags : uint { - None = 0, - Additive = 0x01, // blend mode: SrcAlpha / One (vs default SrcAlpha / InvSrcAlpha) - Billboard = 0x02, + None = 0, + Additive = 0x01, + Billboard = 0x02, FaceCamera = 0x04, - AttachLocal= 0x08, // particles follow parent anchor frame + AttachLocal = 0x08, } /// -/// Per-emitter configuration from the ParticleEmitterInfo dat. -/// See r04 §1 + DatReaderWriter.ParticleEmitterInfo. +/// Per-emitter configuration from the retail ParticleEmitterInfo +/// dat object. /// public sealed class EmitterDesc { - public uint DatId { get; init; } - public ParticleType Type { get; init; } - public EmitterFlags Flags { get; init; } - public uint TextureSurfaceId { get; init; } // 0x06xxxxxx - public uint SoundOnSpawn { get; init; } + public uint DatId { get; init; } + public ParticleType Type { get; init; } + public ParticleEmitterKind EmitterKind { get; init; } = ParticleEmitterKind.BirthratePerSec; + public EmitterFlags Flags { get; init; } + public uint TextureSurfaceId { get; init; } + public uint GfxObjId { get; init; } + public uint HwGfxObjId { get; init; } + public uint SoundOnSpawn { get; init; } - // Emission behavior - public float EmitRate { get; init; } // particles / sec - public int MaxParticles { get; init; } - public float LifetimeMin { get; init; } - public float LifetimeMax { get; init; } - public float StartDelay { get; init; } - public float TotalDuration { get; init; } // 0 = infinite + // Emission behavior. + public float Birthrate { get; init; } + public float EmitRate { get; init; } + public int MaxParticles { get; init; } + public int InitialParticles { get; init; } + public int TotalParticles { get; init; } + public float LifetimeMin { get; init; } + public float LifetimeMax { get; init; } + public float Lifespan { get; init; } + public float LifespanRand { get; init; } + public float StartDelay { get; init; } + public float TotalDuration { get; init; } - // Spawn geometry (disk annulus perpendicular to OffsetDir) - public Vector3 OffsetDir { get; init; } = new(0, 0, 1); - public float MinOffset { get; init; } - public float MaxOffset { get; init; } - public float SpawnDiskRadius { get; init; } + // Spawn geometry. + public Vector3 OffsetDir { get; init; } = new(0, 0, 1); + public float MinOffset { get; init; } + public float MaxOffset { get; init; } + public float SpawnDiskRadius { get; init; } - // Initial kinematics - public Vector3 InitialVelocity { get; init; } - public float VelocityJitter { get; init; } - public Vector3 Gravity { get; init; } = new(0, 0, -9.8f); + // Kinematics. A/B/C are the retail vector coefficients. + public Vector3 InitialVelocity { get; init; } + public float VelocityJitter { get; init; } + public Vector3 Gravity { get; init; } = new(0, 0, -9.8f); + public Vector3 A { get; init; } + public float MinA { get; init; } = 1f; + public float MaxA { get; init; } = 1f; + public Vector3 B { get; init; } + public float MinB { get; init; } = 1f; + public float MaxB { get; init; } = 1f; + public Vector3 C { get; init; } + public float MinC { get; init; } = 1f; + public float MaxC { get; init; } = 1f; - // Appearance over lifetime (retail: start + end, linearly interpolated) - public uint StartColorArgb { get; init; } = 0xFFFFFFFF; - public uint EndColorArgb { get; init; } = 0xFFFFFFFF; - public float StartAlpha { get; init; } = 1f; - public float EndAlpha { get; init; } = 0f; - public float StartSize { get; init; } = 0.5f; - public float EndSize { get; init; } = 0.5f; - public float StartRotation { get; init; } - public float EndRotation { get; init; } + // Appearance over lifetime. + public uint StartColorArgb { get; init; } = 0xFFFFFFFF; + public uint EndColorArgb { get; init; } = 0xFFFFFFFF; + public float StartAlpha { get; init; } = 1f; + public float EndAlpha { get; init; } = 0f; + public float StartSize { get; init; } = 0.5f; + public float EndSize { get; init; } = 0.5f; + public float ScaleRand { get; init; } + public float TransRand { get; init; } + public float StartRotation { get; init; } + public float EndRotation { get; init; } } /// /// A PhysicsScript (0x3Axxxxxx range in retail) is a list of hooks to /// fire at specific start-times. Each hook creates an emitter or plays /// a sound. Chaining hooks at different times gives "animation". -/// See r04 §6. /// public sealed class PhysicsScript { @@ -98,34 +131,43 @@ public sealed class PhysicsScript public sealed record PhysicsScriptHook( float StartTime, PhysicsScriptHookType Type, - uint RefDataId, // EmitterInfo / Sound / PartTransform - int PartIndex, // attach to this part + uint RefDataId, + int PartIndex, Vector3 Offset, bool IsParentLocal); public enum PhysicsScriptHookType { - CreateParticle = 18, // matches retail animation-hook type - DestroyParticle= 19, - PlaySound = 1, - AnimationDone = 2, + CreateParticle = 18, + DestroyParticle = 19, + PlaySound = 1, + AnimationDone = 2, } /// -/// Individual runtime particle. Owned by the ParticleSystem; -/// advanced per-frame. +/// Individual runtime particle. Owned by the ParticleSystem. /// public struct Particle { - public Vector3 Position; - public Vector3 Velocity; - public float SpawnedAt; - public float Lifetime; // seconds - public float Age; - public uint ColorArgb; // current - public float Size; - public float Rotation; - public bool Alive; + public Vector3 EmissionOrigin; + public Quaternion SpawnRotation; + public Vector3 Position; + public Vector3 Velocity; + public Vector3 Offset; + public Vector3 A; + public Vector3 B; + public Vector3 C; + public float SpawnedAt; + public float Lifetime; + public float Age; + public float StartSize; + public float EndSize; + public float StartAlpha; + public float EndAlpha; + public uint ColorArgb; + public float Size; + public float Rotation; + public bool Alive; } /// @@ -134,16 +176,20 @@ public struct Particle /// public sealed class ParticleEmitter { - public EmitterDesc Desc { get; init; } = null!; - public Vector3 AnchorPos { get; set; } - public Quaternion AnchorRot { get; set; } = Quaternion.Identity; - public uint AttachedObjectId { get; set; } // 0 = world-space only - public int AttachedPartIndex { get; set; } = -1; - public Particle[] Particles { get; init; } = null!; - public int ActiveCount; - public float EmittedAccumulator; // fractional particles pending - public float StartedAt; // game-time seconds - public bool Finished; + public EmitterDesc Desc { get; init; } = null!; + public Vector3 AnchorPos { get; set; } + public Quaternion AnchorRot { get; set; } = Quaternion.Identity; + public uint AttachedObjectId { get; set; } + public int AttachedPartIndex { get; set; } = -1; + public Particle[] Particles { get; init; } = null!; + public ParticleRenderPass RenderPass { get; init; } + public int ActiveCount; + public float EmittedAccumulator; + public float StartedAt; + public float LastEmitTime; + public Vector3 LastEmitOffset; + public int TotalEmitted; + public bool Finished; } /// @@ -151,20 +197,25 @@ public sealed class ParticleEmitter /// public interface IParticleSystem { - /// Spawn an emitter attached to a world position (or entity). - int SpawnEmitter(EmitterDesc desc, Vector3 anchor, Quaternion? rot = null, - uint attachedObjectId = 0, int attachedPartIndex = -1); + /// Spawn an emitter attached to a world position or entity. + int SpawnEmitter( + EmitterDesc desc, + Vector3 anchor, + Quaternion? rot = null, + uint attachedObjectId = 0, + int attachedPartIndex = -1, + ParticleRenderPass renderPass = ParticleRenderPass.Scene); - /// Fire a full PhysicsScript at a target (the retail PlayScript dispatch). + /// Fire a full PhysicsScript at a target. void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f); /// Advance all active emitters by dt seconds. void Tick(float dt); - /// Stop an emitter early (e.g. cast interrupted). + /// Stop an emitter early. void StopEmitter(int handle, bool fadeOut); - /// Current active particle count (for HUD stats). + /// Current active particle count. int ActiveParticleCount { get; } - int ActiveEmitterCount { get; } + int ActiveEmitterCount { get; } } diff --git a/src/AcDream.Core/World/SkyDescLoader.cs b/src/AcDream.Core/World/SkyDescLoader.cs index ada2753..b8204ba 100644 --- a/src/AcDream.Core/World/SkyDescLoader.cs +++ b/src/AcDream.Core/World/SkyDescLoader.cs @@ -34,6 +34,7 @@ public sealed class SkyObjectData public float TexVelocityX; public float TexVelocityY; public uint GfxObjId; + public uint PesObjectId; public uint Properties; /// @@ -531,6 +532,7 @@ public static class SkyDescLoader TexVelocityX = s.TexVelocityX, TexVelocityY = s.TexVelocityY, GfxObjId = s.DefaultGfxObjectId?.DataId ?? 0u, + PesObjectId = s.DefaultPesObjectId?.DataId ?? 0u, Properties = s.Properties, }; diff --git a/tests/AcDream.Core.Tests/Meshing/TranslucencyKindTests.cs b/tests/AcDream.Core.Tests/Meshing/TranslucencyKindTests.cs index 04a6c4b..2a4b960 100644 --- a/tests/AcDream.Core.Tests/Meshing/TranslucencyKindTests.cs +++ b/tests/AcDream.Core.Tests/Meshing/TranslucencyKindTests.cs @@ -4,15 +4,12 @@ using DatReaderWriter.Enums; namespace AcDream.Core.Tests.Meshing; /// -/// Verifies that maps -/// SurfaceType flag combinations to the correct -/// according to the documented priority order: -/// Additive > InvAlpha > AlphaBlend (Alpha|Translucent) > ClipMap > Opaque +/// Verifies the retail surface-state mapping used by the GL render split. +/// Priority order is: +/// Translucent+ClipMap override, Additive, InvAlpha, AlphaBlend, ClipMap, Opaque. /// public class TranslucencyKindTests { - // ── Opaque cases ──────────────────────────────────────────────────────── - [Fact] public void Opaque_FromZeroFlags_ReturnsOpaque() => Assert.Equal(TranslucencyKind.Opaque, TranslucencyKindExtensions.FromSurfaceType((SurfaceType)0)); @@ -25,8 +22,6 @@ public class TranslucencyKindTests public void Opaque_FromBase1ImageFlag_ReturnsOpaque() => Assert.Equal(TranslucencyKind.Opaque, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Base1Image)); - // ── ClipMap cases ─────────────────────────────────────────────────────── - [Fact] public void ClipMap_FromBase1ClipMapFlag_ReturnsClipMap() => Assert.Equal(TranslucencyKind.ClipMap, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Base1ClipMap)); @@ -36,8 +31,6 @@ public class TranslucencyKindTests => Assert.Equal(TranslucencyKind.ClipMap, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Base1ClipMap | SurfaceType.Gouraud)); - // ── AlphaBlend cases ──────────────────────────────────────────────────── - [Fact] public void AlphaBlend_FromAlphaFlag_ReturnsAlphaBlend() => Assert.Equal(TranslucencyKind.AlphaBlend, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Alpha)); @@ -56,7 +49,14 @@ public class TranslucencyKindTests => Assert.Equal(TranslucencyKind.AlphaBlend, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Alpha | SurfaceType.Base1ClipMap)); - // ── InvAlpha cases ────────────────────────────────────────────────────── + [Fact] + public void AlphaBlend_TranslucentClipMapAdditiveCloud_ReturnsAlphaBlend() + => Assert.Equal(TranslucencyKind.AlphaBlend, + TranslucencyKindExtensions.FromSurfaceType( + SurfaceType.Base1ClipMap + | SurfaceType.Translucent + | SurfaceType.Alpha + | SurfaceType.Additive)); [Fact] public void InvAlpha_FromInvAlphaFlag_ReturnsInvAlpha() @@ -67,15 +67,40 @@ public class TranslucencyKindTests => Assert.Equal(TranslucencyKind.InvAlpha, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.InvAlpha | SurfaceType.Alpha)); - // ── Additive cases ────────────────────────────────────────────────────── - [Fact] public void Additive_FromAdditiveFlag_ReturnsAdditive() => Assert.Equal(TranslucencyKind.Additive, TranslucencyKindExtensions.FromSurfaceType(SurfaceType.Additive)); [Fact] - public void Additive_AdditiveBeatsAllOther() + public void Additive_AdditiveBeatsNonTranslucentBlendFlags() => Assert.Equal(TranslucencyKind.Additive, TranslucencyKindExtensions.FromSurfaceType( SurfaceType.Additive | SurfaceType.InvAlpha | SurfaceType.Alpha | SurfaceType.Base1ClipMap)); + + [Fact] + public void OpacityFromSurfaceTranslucency_NonTranslucentIgnoresRawValue() + { + Assert.Equal(1f, TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(SurfaceType.Base1Image, 0f)); + Assert.Equal(1f, TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(SurfaceType.Base1Image, 0.75f)); + } + + [Fact] + public void OpacityFromSurfaceTranslucency_TranslucentInvertsAndClamps() + { + Assert.Equal(1f, TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(SurfaceType.Translucent, -0.25f)); + Assert.Equal(0.75f, TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(SurfaceType.Translucent, 0.25f)); + Assert.Equal(0f, TranslucencyKindExtensions.OpacityFromSurfaceTranslucency(SurfaceType.Translucent, 1.25f)); + } + + [Fact] + public void DisablesFixedFunctionFog_RawAdditiveEvenWhenBlendForcedToAlpha() + { + var cloud = SurfaceType.Base1ClipMap + | SurfaceType.Translucent + | SurfaceType.Alpha + | SurfaceType.Additive; + + Assert.Equal(TranslucencyKind.AlphaBlend, TranslucencyKindExtensions.FromSurfaceType(cloud)); + Assert.True(TranslucencyKindExtensions.DisablesFixedFunctionFog(cloud)); + } } diff --git a/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs b/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs new file mode 100644 index 0000000..1fc53e6 --- /dev/null +++ b/tests/AcDream.Core.Tests/Vfx/ParticleHookSinkTests.cs @@ -0,0 +1,95 @@ +using System.Numerics; +using AcDream.Core.Vfx; +using DatReaderWriter.Types; +using Xunit; + +namespace AcDream.Core.Tests.Vfx; + +public sealed class ParticleHookSinkTests +{ + private static EmitterDesc MakeDesc(uint id, bool attachLocal, int totalParticles = 0) + { + return new EmitterDesc + { + DatId = id, + Type = ParticleType.Still, + Flags = EmitterFlags.Billboard | (attachLocal ? EmitterFlags.AttachLocal : 0), + EmitterKind = ParticleEmitterKind.BirthratePerSec, + MaxParticles = 4, + InitialParticles = 1, + TotalParticles = totalParticles, + LifetimeMin = 0.05f, LifetimeMax = 0.05f, Lifespan = 0.05f, + StartSize = 1f, EndSize = 1f, + StartAlpha = 1f, EndAlpha = 1f, + Birthrate = 1000f, // effectively never re-emit + }; + } + + [Fact] + public void UpdateEntityAnchor_WithAttachLocal_MovesParticleToLiveAnchor() + { + var registry = new EmitterDescRegistry(); + registry.Register(MakeDesc(0x32000010u, attachLocal: true)); + var sys = new ParticleSystem(registry, new System.Random(42)); + var sink = new ParticleHookSink(sys); + + var hook = new CreateParticleHook + { + EmitterInfoId = 0x32000010u, + EmitterId = 0, + PartIndex = 0, + Offset = new Frame(), + }; + // First spawn at world origin. + sink.OnHook(entityId: 0xCAFEu, entityWorldPosition: Vector3.Zero, hook); + sys.Tick(0.01f); + + var live1 = System.Linq.Enumerable.Single(sys.EnumerateLive()); + Assert.Equal(Vector3.Zero, live1.Emitter.Particles[live1.Index].Position); + + // Move the parent to (5, 7, 0) — UpdateEntityAnchor must propagate. + sink.UpdateEntityAnchor(0xCAFEu, new Vector3(5, 7, 0), Quaternion.Identity); + sys.Tick(0.01f); + + var live2 = System.Linq.Enumerable.Single(sys.EnumerateLive()); + Assert.Equal(new Vector3(5, 7, 0), live2.Emitter.Particles[live2.Index].Position); + } + + [Fact] + public void EmitterDied_PrunesPerEntityHandleTracking() + { + // M4: ConcurrentBag couldn't drop entries when a particle + // emitter expired naturally, so per-entity tracking grew without + // bound. The sink now subscribes to ParticleSystem.EmitterDied + // and prunes both the (entity,key) map and the per-entity set. + var registry = new EmitterDescRegistry(); + registry.Register(MakeDesc(0x32000020u, attachLocal: false, totalParticles: 1)); + var sys = new ParticleSystem(registry, new System.Random(42)); + var sink = new ParticleHookSink(sys); + + var hook = new CreateParticleHook + { + EmitterInfoId = 0x32000020u, + EmitterId = 0xABCDu, // logical key + PartIndex = 0, + Offset = new Frame(), + }; + sink.OnHook(0xCAFEu, Vector3.Zero, hook); + Assert.Equal(1, sys.ActiveEmitterCount); + + // TotalParticles=1 cap hit immediately by the InitialParticles spawn, + // so the emitter Finishes once its single particle expires (0.05s + // lifetime). After this, EmitterDied has fired and tracking is pruned. + for (int i = 0; i < 5; i++) sys.Tick(0.05f); + Assert.Equal(0, sys.ActiveEmitterCount); + + // A fresh spawn for the same (entity, key) succeeds and is the only + // live emitter — i.e., the previous handle was pruned cleanly. + sink.OnHook(0xCAFEu, Vector3.Zero, hook); + Assert.Equal(1, sys.ActiveEmitterCount); + + sink.StopAllForEntity(0xCAFEu, fadeOut: false); + sys.Tick(0.01f); + Assert.Equal(0, sys.ActiveEmitterCount); + } +} diff --git a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs index 947efe5..edc213f 100644 --- a/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs +++ b/tests/AcDream.Core.Tests/Vfx/ParticleSystemTests.cs @@ -34,6 +34,43 @@ public sealed class ParticleSystemTests }; } + private static EmitterDesc MakeInitialParticleDesc( + ParticleType type, + Vector3 a, + Vector3 b, + Vector3 c) + { + return new EmitterDesc + { + DatId = 0x3200AA01u, + Type = type, + MaxParticles = 1, + InitialParticles = 1, + LifetimeMin = 10f, + LifetimeMax = 10f, + Lifespan = 10f, + LifespanRand = 0f, + OffsetDir = Vector3.UnitZ, + MinOffset = 0f, + MaxOffset = 0f, + InitialVelocity = Vector3.Zero, + Gravity = Vector3.Zero, + A = a, + MinA = 1f, + MaxA = 1f, + B = b, + MinB = 1f, + MaxB = 1f, + C = c, + MinC = 1f, + MaxC = 1f, + StartSize = 0.5f, + EndSize = 0.5f, + StartAlpha = 1f, + EndAlpha = 1f, + }; + } + [Fact] public void SpawnEmitter_ReturnsPositiveHandle_AndTracksEmitter() { @@ -60,7 +97,7 @@ public sealed class ParticleSystemTests public void Tick_ParticlesDieAtLifetime() { var sys = MakeSystem(); - sys.SpawnEmitter(MakeDesc(emitRate: 20f, lifetime: 0.5f, maxParticles: 100), Vector3.Zero); + int handle = sys.SpawnEmitter(MakeDesc(emitRate: 20f, lifetime: 0.5f, maxParticles: 100), Vector3.Zero); // Use many short ticks so we can observe the death curve. // At 20/sec with 0.5s lifetime and a stable emission pool, the @@ -69,11 +106,10 @@ public sealed class ParticleSystemTests int steadyState = sys.ActiveParticleCount; Assert.InRange(steadyState, 7, 13); - // Now advance further with no spawns (stop emitter); all should die. - sys.SpawnEmitter(MakeDesc(emitRate: 0f, maxParticles: 1), Vector3.Zero); // noop - // Continue time; particles age past lifetime. + // Now advance further with no new spawns; all should die. + sys.StopEmitter(handle, fadeOut: true); for (int i = 0; i < 30; i++) sys.Tick(0.05f); // 1.5s more than lifetime - Assert.True(sys.ActiveParticleCount <= steadyState); + Assert.Equal(0, sys.ActiveParticleCount); } [Fact] @@ -100,7 +136,7 @@ public sealed class ParticleSystemTests var desc = new EmitterDesc { DatId = 0x32000002u, - Type = ParticleType.Parabolic, + Type = ParticleType.ParabolicLVGA, EmitRate = 10f, MaxParticles = 100, LifetimeMin = 2f, LifetimeMax = 2f, @@ -192,7 +228,7 @@ public sealed class ParticleSystemTests } [Fact] - public void MaxParticles_CapEnforced_OverwriteOldest() + public void MaxParticles_CapEnforced() { var sys = MakeSystem(); // Low cap, high rate, long life → rapidly hit cap. @@ -219,4 +255,239 @@ public sealed class ParticleSystemTests reg.Register(desc); Assert.Same(desc, reg.Get(0x32001234u)); } + + [Fact] + public void LocalVelocity_TransformsABySpawnRotation() + { + var sys = MakeSystem(); + var desc = MakeInitialParticleDesc( + ParticleType.LocalVelocity, + Vector3.UnitX, + Vector3.Zero, + Vector3.Zero); + + sys.SpawnEmitter(desc, Vector3.Zero, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI * 0.5f)); + sys.Tick(1f); + + var live = sys.EnumerateLive().Single(); + var pos = live.Emitter.Particles[live.Index].Position; + Assert.InRange(pos.X, -0.0001f, 0.0001f); + Assert.InRange(pos.Y, 0.9999f, 1.0001f); + } + + [Fact] + public void GlobalVelocity_DoesNotTransformABySpawnRotation() + { + var sys = MakeSystem(); + var desc = MakeInitialParticleDesc( + ParticleType.GlobalVelocity, + Vector3.UnitX, + Vector3.Zero, + Vector3.Zero); + + sys.SpawnEmitter(desc, Vector3.Zero, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI * 0.5f)); + sys.Tick(1f); + + var live = sys.EnumerateLive().Single(); + var pos = live.Emitter.Particles[live.Index].Position; + Assert.InRange(pos.X, 0.9999f, 1.0001f); + Assert.InRange(pos.Y, -0.0001f, 0.0001f); + } + + [Fact] + public void ParabolicLVLA_TransformsLocalAcceleration() + { + var sys = MakeSystem(); + var desc = MakeInitialParticleDesc( + ParticleType.ParabolicLVLA, + Vector3.Zero, + Vector3.UnitX, + Vector3.Zero); + + sys.SpawnEmitter(desc, Vector3.Zero, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI * 0.5f)); + sys.Tick(1f); + + var live = sys.EnumerateLive().Single(); + var pos = live.Emitter.Particles[live.Index].Position; + Assert.InRange(pos.X, -0.0001f, 0.0001f); + Assert.InRange(pos.Y, 0.4999f, 0.5001f); + } + + [Fact] + public void ParabolicLVGA_KeepsGlobalAcceleration() + { + var sys = MakeSystem(); + var desc = MakeInitialParticleDesc( + ParticleType.ParabolicLVGA, + Vector3.Zero, + Vector3.UnitX, + Vector3.Zero); + + sys.SpawnEmitter(desc, Vector3.Zero, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI * 0.5f)); + sys.Tick(1f); + + var live = sys.EnumerateLive().Single(); + var pos = live.Emitter.Particles[live.Index].Position; + Assert.InRange(pos.X, 0.4999f, 0.5001f); + Assert.InRange(pos.Y, -0.0001f, 0.0001f); + } + + [Fact] + public void EmitterDescRegistry_FromDat_PreservesRetailEnumValuesAndRates() + { + var dat = new DatReaderWriter.DBObjs.ParticleEmitter + { + EmitterType = DatReaderWriter.Enums.EmitterType.BirthratePerSec, + ParticleType = DatReaderWriter.Enums.ParticleType.Swarm, + GfxObjId = 0x01000001u, + HwGfxObjId = 0x01000002u, + Birthrate = 0.25, + MaxParticles = 17, + InitialParticles = 3, + TotalParticles = 9, + TotalSeconds = 4, + Lifespan = 2, + LifespanRand = 0.5, + A = new Vector3(1, 0, 0), + MinA = 0.5f, + MaxA = 2f, + StartScale = 0.2f, + FinalScale = 0.8f, + StartTrans = 1f, + FinalTrans = 0f, + IsParentLocal = true, + }; + + var desc = EmitterDescRegistry.FromDat(0x32000099u, dat); + + Assert.Equal(ParticleType.Swarm, desc.Type); + Assert.Equal(ParticleEmitterKind.BirthratePerSec, desc.EmitterKind); + Assert.Equal(4f, desc.EmitRate); + Assert.Equal(0x01000001u, desc.GfxObjId); + Assert.Equal(0x01000002u, desc.HwGfxObjId); + Assert.Equal(3, desc.InitialParticles); + Assert.Equal(9, desc.TotalParticles); + Assert.Equal(1.5f, desc.LifetimeMin); + Assert.Equal(2.5f, desc.LifetimeMax); + Assert.Equal(0f, desc.StartAlpha); + Assert.Equal(1f, desc.EndAlpha); + Assert.Equal(EmitterFlags.Billboard | EmitterFlags.FaceCamera | EmitterFlags.AttachLocal, desc.Flags); + Assert.True((desc.Flags & EmitterFlags.AttachLocal) != 0); + } + + [Fact] + public void UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor() + { + // Retail ParticleEmitter::UpdateParticles 0x0051d2d4 reads the live + // parent frame each tick when is_parent_local=1. With the cameraOffset + // hack removed, AttachLocal correctness now depends on the owning + // subsystem updating AnchorPos every frame via UpdateEmitterAnchor. + var sys = MakeSystem(); + var desc = new EmitterDesc + { + DatId = 0x32AABBCCu, + Type = ParticleType.Still, + Flags = EmitterFlags.AttachLocal | EmitterFlags.Billboard, + MaxParticles = 1, + InitialParticles = 1, + LifetimeMin = 100f, LifetimeMax = 100f, Lifespan = 100f, + StartSize = 1f, EndSize = 1f, + StartAlpha = 1f, EndAlpha = 1f, + // Zero motion + zero offset so position == origin == AnchorPos. + }; + int handle = sys.SpawnEmitter(desc, anchor: new Vector3(10, 0, 0)); + sys.Tick(0.01f); + + var p1 = sys.EnumerateLive().Single().Emitter.Particles[0]; + Assert.Equal(new Vector3(10, 0, 0), p1.Position); + + // Move the live anchor; AttachLocal should track it on the next tick. + sys.UpdateEmitterAnchor(handle, new Vector3(50, 20, 5)); + sys.Tick(0.01f); + + var p2 = sys.EnumerateLive().Single().Emitter.Particles[0]; + Assert.Equal(new Vector3(50, 20, 5), p2.Position); + } + + [Fact] + public void UpdateEmitterAnchor_AttachLocalCleared_ParticleFrozenAtSpawnOrigin() + { + // is_parent_local=0 → particle uses its frozen EmissionOrigin; later + // anchor updates must NOT move it (retail's "frame snapshotted at + // spawn" semantics). + var sys = MakeSystem(); + var desc = new EmitterDesc + { + DatId = 0x32AABBCDu, + Type = ParticleType.Still, + Flags = EmitterFlags.Billboard, // NO AttachLocal + MaxParticles = 1, + InitialParticles = 1, + LifetimeMin = 100f, LifetimeMax = 100f, Lifespan = 100f, + StartSize = 1f, EndSize = 1f, + StartAlpha = 1f, EndAlpha = 1f, + }; + int handle = sys.SpawnEmitter(desc, anchor: new Vector3(10, 0, 0)); + sys.Tick(0.01f); + + sys.UpdateEmitterAnchor(handle, new Vector3(99, 99, 99)); + sys.Tick(0.01f); + + var p = sys.EnumerateLive().Single().Emitter.Particles[0]; + Assert.Equal(new Vector3(10, 0, 0), p.Position); + } + + [Fact] + public void EmitterDied_FiresOncePerHandle_AfterAllParticlesExpire() + { + var sys = MakeSystem(); + var fired = new System.Collections.Generic.List(); + sys.EmitterDied += h => fired.Add(h); + + int handle = sys.SpawnEmitter(MakeDesc(emitRate: 5f, lifetime: 0.2f, maxParticles: 4), Vector3.Zero); + sys.StopEmitter(handle, fadeOut: false); // kill emitter + all particles immediately + sys.Tick(0.01f); + + Assert.Single(fired); + Assert.Equal(handle, fired[0]); + Assert.False(sys.IsEmitterAlive(handle)); + } + + [Fact] + public void Birthrate_PerSec_EmitsOnePerTickWhenIntervalElapsed() + { + // Retail ParticleEmitterInfo::ShouldEmitParticle 0x00517420 checks + // (cur_time - last_emit_time) > birthrate. RecordParticleEmission + // 0x0051c870 then sets last_emit_time = cur_time, so retail's + // UpdateParticles fires AT MOST one EmitParticle per frame + // (the dispatch is `if (ShouldEmit) EmitParticle()`, not a loop). + // Lock that behavior in. + var sys = MakeSystem(); + var desc = new EmitterDesc + { + DatId = 0x32AAAA01u, + Type = ParticleType.Still, + EmitterKind = ParticleEmitterKind.BirthratePerSec, + Birthrate = 0.05f, // 50ms minimum between emits + EmitRate = 0f, // disable the EmitRate fallback path + MaxParticles = 100, + LifetimeMin = 100f, LifetimeMax = 100f, Lifespan = 100f, + StartSize = 1f, EndSize = 1f, + StartAlpha = 1f, EndAlpha = 1f, + }; + sys.SpawnEmitter(desc, Vector3.Zero); + + // Single 1-second tick. Retail-faithful behavior: exactly one + // particle emits, regardless of how many birthrate intervals fit in dt. + sys.Tick(1.0f); + Assert.Equal(1, sys.ActiveParticleCount); + + // Subsequent small ticks each emit once if birthrate has elapsed. + sys.Tick(0.06f); // > 0.05s since last emit + Assert.Equal(2, sys.ActiveParticleCount); + + // A tick smaller than birthrate adds nothing. + sys.Tick(0.01f); + Assert.Equal(2, sys.ActiveParticleCount); + } } diff --git a/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs b/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs index 0eafa2e..d86cb57 100644 --- a/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs +++ b/tests/AcDream.Core.Tests/Vfx/PhysicsScriptRunnerTests.cs @@ -207,4 +207,28 @@ public sealed class PhysicsScriptRunnerTests runner.Tick(0.5f); // total 0.6 > 0.5 pause Assert.Single(sink.Calls); } + + [Fact] + public void CallPES_SelfLoopWithPause_DoesNotReplaceCurrentInstance() + { + var script = BuildScript( + (0.0, new CallPESHook { PES = 0xAA, Pause = 30f }), + (0.0, CreateHook(123))); + + var sink = new RecordingSink(); + var runner = MakeRunner(sink, (0xAAu, script)); + runner.Play(scriptId: 0xAA, entityId: 0x7, anchorWorldPos: Vector3.Zero); + + runner.Tick(0.1f); + + Assert.Single(sink.Calls); + Assert.Equal(123u, ((CreateParticleHook)sink.Calls[0].Hook).EmitterInfoId.DataId); + Assert.Equal(1, runner.ActiveScriptCount); + + runner.Tick(29.8f); + Assert.Single(sink.Calls); + + runner.Tick(0.3f); + Assert.Equal(2, sink.Calls.Count); + } } diff --git a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs index 3331e85..d07d0a6 100644 --- a/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs +++ b/tests/AcDream.Core.Tests/World/SkyDescLoaderTests.cs @@ -72,6 +72,29 @@ public sealed class SkyDescLoaderTests Assert.Equal(FogMode.Linear, kf.FogMode); } + [Fact] + public void LoadFromRegion_CapturesSkyObjectPesId() + { + var region = MakeRegion(dirBright: 1.0f, rBgrOrder: 255); + var dg = region.SkyInfo!.DayGroups[0]; + dg.SkyObjects.Add(new SkyObject + { + BeginTime = 0f, + EndTime = 1f, + DefaultGfxObjectId = 0x01004C44u, + DefaultPesObjectId = 0x3300042Cu, + Properties = 0x05, + }); + + var loaded = SkyDescLoader.LoadFromRegion(region); + + Assert.NotNull(loaded); + var obj = Assert.Single(loaded!.DayGroups[0].SkyObjects); + Assert.Equal(0x01004C44u, obj.GfxObjId); + Assert.Equal(0x3300042Cu, obj.PesObjectId); + Assert.True(obj.IsPostScene); + } + [Fact] public void LoadFromRegion_SunColor_UsesRetailSunVectorMagnitude() { diff --git a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs index 7acf0d1..4fde925 100644 --- a/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs +++ b/tests/AcDream.Core.Tests/World/WorldTimeDebugTests.cs @@ -28,7 +28,8 @@ public sealed class WorldTimeDebugTests // fraction 1/16: solve (t + 7/16*D) mod D = 1/16*D // → t = (1/16 - 7/16) * D mod D = -6/16 * D mod D = 10/16 * D. double targetFraction = 1.0 / 16.0; // Darktide-and-Half - double syncTick = (targetFraction - (7.0 / 16.0) + 1.0) * DerethDateTime.DayTicks; + double syncTick = targetFraction * DerethDateTime.DayTicks - DerethDateTime.OriginOffsetTicks; + while (syncTick < 0) syncTick += DerethDateTime.DayTicks; var service = new WorldTimeService(SkyStateProvider.Default()); service.SyncFromServer(syncTick);