perf(vfx): port retail particle visibility degradation

Resolve DAT-authored particle ranges from the hardware GfxObj, apply retail distance and completed-cell visibility gates, and preserve the exact finite/infinite off-view update semantics. This removes dense-world simulation work without shortening terrain, entity, fog, or streaming distance.

Publish doorway-clipped outdoor cells through a focused frame controller, retain effect cell identity for outdoor statics, reject hidden emitters before particle-slot scans, and offer an explicit opt-in Extended particle range.

Release build succeeds and all 5,857 tests pass with five intentional skips. Retail-conformance, architecture, and adversarial review cycles are clean; connected Aerlinthe visual/performance gate pending.

Co-authored-by: OpenAI Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-17 15:27:36 +02:00
parent 82789eea88
commit f1ba147ac5
29 changed files with 1810 additions and 125 deletions

View file

@ -105,7 +105,7 @@ accepted-divergence entries (#96, #49, #50).
--- ---
## 3. Documented approximation (AP) — 87 active rows ## 3. Documented approximation (AP) — 89 active rows
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84 Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
@ -214,6 +214,8 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| ~~AP-114~~ | **RETIRED 2026-07-14 (protection-effect corrective gate)** — the particle renderer no longer replaces every authored GfxObj with one bounding-box quad. Retail `Always2D` classification preserves mode-1/no-degrade full meshes through the modern shared mesh buffer and leaves only other degrade modes on the billboard path; stable emitter handles balance mesh ownership. | `src/AcDream.App/Rendering/ParticleRenderer.cs`; `RetailParticleGeometryClassifier.cs`; `particle_mesh.vert/.frag` | — | — | `CPhysicsPart::Draw @ 0x0050D7A0`; `CPhysicsPart::Always2D @ 0x0050D8A0`; `ParticleEmitter::SetInfo @ 0x0051CE90`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` | | ~~AP-114~~ | **RETIRED 2026-07-14 (protection-effect corrective gate)** — the particle renderer no longer replaces every authored GfxObj with one bounding-box quad. Retail `Always2D` classification preserves mode-1/no-degrade full meshes through the modern shared mesh buffer and leaves only other degrade modes on the billboard path; stable emitter handles balance mesh ownership. | `src/AcDream.App/Rendering/ParticleRenderer.cs`; `RetailParticleGeometryClassifier.cs`; `particle_mesh.vert/.frag` | — | — | `CPhysicsPart::Draw @ 0x0050D7A0`; `CPhysicsPart::Always2D @ 0x0050D8A0`; `ParticleEmitter::SetInfo @ 0x0051CE90`; `docs/research/2026-07-13-retail-projectile-vfx-pseudocode.md` |
| AP-115 | The DAT-authored portal-space viewport and its animation `SoundTweakedHook` are live, but the separate `ClientUISystem` enter/exit sound enums and centered repeating `"In Portal Space - Please Wait..."` display string are not yet presented. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; teleport event switch in `GameWindow.cs` | acdream has no retained cross-stack centered display-string owner or ClientUISystem sound-table-enum resolver yet; routing the line into chat or inventing direct wave IDs would be less faithful | Portal travel has the correct animated wormhole, timing, direct viewport switch, view-plane transitions, and animation-authored sound, but lacks retail's short UI cue sounds and center notice | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` | | AP-115 | The DAT-authored portal-space viewport and its animation `SoundTweakedHook` are live, but the separate `ClientUISystem` enter/exit sound enums and centered repeating `"In Portal Space - Please Wait..."` display string are not yet presented. | `src/AcDream.App/Rendering/PortalTunnelPresentation.cs`; teleport event switch in `GameWindow.cs` | acdream has no retained cross-stack centered display-string owner or ClientUISystem sound-table-enum resolver yet; routing the line into chat or inventing direct wave IDs would be less faithful | Portal travel has the correct animated wormhole, timing, direct viewport switch, view-plane transitions, and animation-authored sound, but lacks retail's short UI cue sounds and center notice | `gmSmartBoxUI::BeginTeleportAnimation @ 0x004D6300`; `gmSmartBoxUI::UseTime @ 0x004D6E30` |
| AP-116 | Optional Display setting `Particle Range = Extended` multiplies DAT-authored particle degradation distances by 2; default `Retail` uses exact values | `src/AcDream.UI.Abstractions/Panels/Settings/DisplaySettings.cs`; `src/AcDream.App/Rendering/Vfx/ParticleVisibilityController.cs`; `src/AcDream.Core/Vfx/ParticleSystem.cs` | Explicit opt-in for players who value distant VFX; it changes no terrain, scenery, entity, fog, or streaming distance and is reversible live | Extended can roughly enlarge the active particle area and reduce the CPU gain from MP2; distant VFX remain visible beyond retail's authored cutoff | `CPhysicsPart::GetMaxDegradeDistance @ 0x0050D510`; `GfxObjDegradeInfo::get_max_degrade_distance @ 0x0051E2D0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
| AP-117 | Outdoor particle `CLandCell::IsInView` state is reconstructed with the modern landscape renderer's per-cell frustum plus active doorway clip-plane/scissor-AABB tests; retail `LScape::landcell_check` uses `Render::get_clip_height` + `Render::block_check` on terrain-cell corner intervals | `src/AcDream.App/Rendering/TerrainModernRenderer.cs` (`CollectVisibleCells`) | The mandatory modern renderer batches terrain by landblock and has no retail `ViewIntervalType` product. Publishing cell visibility from the exact landscape draw slices preserves ownership/order and removes the former object-survivor dependency without adding a second view pipeline | At a terrain cell grazing a frustum or doorway boundary, the conservative AABB test may freeze or resume particles on a slightly different frame than retail; whole regions outside the active doorway slice are rejected, and authored distance, login/portal fail-closed behavior, and indoor PView cells remain exact | `LScape::landcell_check @ 0x005050A0`; `CLandCell::IsInView @ 0x00532CB0`; `CPhysicsObj::ShouldDrawParticles @ 0x0050FE60` |
## 4. Temporary stopgap (TS) — 33 active rows (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation) ## 4. Temporary stopgap (TS) — 33 active rows (TS-20 retired 2026-07-16 — the later named-retail audit disproved the proposed DrawingBSP polygon filter; TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation)

View file

@ -113,6 +113,23 @@ loader when preloading emitter meshes. This is not a second DAT reader:
`CreateBlockingParticleHook::Execute` `0x00526EF0` and `CreateBlockingParticleHook::Execute` `0x00526EF0` and
`ParticleManager::CreateBlockingParticleEmitter` `0x0051B8A0`. `ParticleManager::CreateBlockingParticleEmitter` `0x0051B8A0`.
**Retail particle visibility/degradation seam (2026-07-17).** WorldBuilder's
particle simulator remains a useful DAT-integrator and batching reference, but
it does not carry the live retail client's `CObjCell::IsInView` degradation
path. `EmitterDescRegistry` now resolves the hardware particle GfxObj and its
ordered `GfxObjDegradeInfo` entries through the same sole `DatCollection`;
`ParticleSystem` ports the retail finite/infinite degraded branches; App feeds
the unified PView interior set plus the landscape renderer's independently
computed outdoor landcell set through a focused
`ParticleVisibilityController`. Examination and dedicated-pass emitters carry
an explicit bypass policy; missing/portal world views are empty rather than
fail-open.
No WorldBuilder dependency or second DAT access layer was introduced. Retail
anchors: `CPhysicsPart::GetMaxDegradeDistance` `0x0050D510`,
`GfxObjDegradeInfo::get_max_degrade_distance` `0x0051E2D0`,
`CPhysicsObj::ShouldDrawParticles` `0x0050FE60`, and
`ParticleEmitter::UpdateParticles` `0x0051D180`.
**Retail portal-space viewport adapter (2026-07-15).** **Retail portal-space viewport adapter (2026-07-15).**
`src/AcDream.App/Rendering/PortalTunnelPresentation.cs` uses the extracted `src/AcDream.App/Rendering/PortalTunnelPresentation.cs` uses the extracted
Setup/GfxObj mesh pipeline and mandatory `WbDrawDispatcher` for retail's Setup/GfxObj mesh pipeline and mandatory `WbDrawDispatcher` for retail's

View file

@ -1332,7 +1332,7 @@ hitch and follows.
| **MP-Alloc (safe batch)** | Reuse per-frame buffers: anim pose, particle draw-list, interior partition, animatedIds/drawableCells sets | ✅ SHIPPED + USER-GATED 2026-07-05 — dense-town frame-time spikes 2087ms → 610ms, alloc spikes (3075MB single-frame) eliminated, gen2 GC 511/window → ~0; user confirms FPS steady. 4 commits `b8c05e2b`..`91afea24` | | **MP-Alloc (safe batch)** | Reuse per-frame buffers: anim pose, particle draw-list, interior partition, animatedIds/drawableCells sets | ✅ SHIPPED + USER-GATED 2026-07-05 — dense-town frame-time spikes 2087ms → 610ms, alloc spikes (3075MB single-frame) eliminated, gen2 GC 511/window → ~0; user confirms FPS steady. 4 commits `b8c05e2b`..`91afea24` |
| MP-Alloc (hard sites) | EnvCell settled-camera rebuild gate + physics `Transition` pooling — the residual steady ~1.6MB/frame | ⚪ OPTIONAL follow-up — would lower the median too, but the wild-swing complaint is already resolved; each needs its own careful gate (batch correctness / physics faithfulness) | | MP-Alloc (hard sites) | EnvCell settled-camera rebuild gate + physics `Transition` pooling — the residual steady ~1.6MB/frame | ⚪ OPTIONAL follow-up — would lower the median too, but the wild-swing complaint is already resolved; each needs its own careful gate (batch correctness / physics faithfulness) |
| MP1c | Streaming cutover to pak + hitch gate (vs the 211 ms baseline) | ⚪ after MP-Alloc | | MP1c | Streaming cutover to pak + hitch gate (vs the 211 ms baseline) | ⚪ after MP-Alloc |
| MP2 | Retail distance-degrade port, hide-only cut (retires a divergence row) | ⚪ | | MP2 | Retail particle distance/cell-view degradation: DAT-authored range, exact infinite/finite off-view update branches, emitter-first render scan, optional Extended range | ✅ SHIPPED 2026-07-17 — Aerlinthe hotspot `0x32000223` resolves to retail 64m; landscape/entity streaming distance unchanged |
| MP3 | Arch ECS render world + delta submission (the 300-FPS lever) | ⚪ — note: does NOT fix the steady-state GC churn (that's MP-Alloc); MP3 is the draw-submission throughput lever | | MP3 | Arch ECS render world + delta submission (the 300-FPS lever) | ⚪ — note: does NOT fix the steady-state GC churn (that's MP-Alloc); MP3 is the draw-submission throughput lever |
| MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | ⚪ hard-queued behind M1.5 #137 | | MP4 | Zero-alloc frame loop + flat physics data (residual, post-MP-Alloc) | ⚪ hard-queued behind M1.5 #137 |
| MP5 | Job-system parallelism | ⚪ stretch, evidence-gated | | MP5 | Job-system parallelism | ⚪ stretch, evidence-gated |

View file

@ -633,8 +633,15 @@ include dungeons.
ports `CPhysicsPart::Always2D`: mode-1/no-degrade particles now keep every ports `CPhysicsPart::Always2D`: mode-1/no-degrade particles now keep every
authored mesh polygon through the modern global buffer, while other degrade authored mesh polygon through the modern global buffer, while other degrade
modes retain the billboard path. This restores the four-piece apex of modes retain the billboard path. This restores the four-piece apex of
Armor/protection shield effects. The final two-client visual gate is still Armor/protection shield effects. MP2's 2026-07-17 corrective performance
pending. slice now ports the complete retail particle distance/cell-view gate and
degraded update branches: DAT-authored ranges (Aerlinthe's dominant swarm is
64 world units), exact infinite-emitter freeze/finite-emitter conditional
stop bookkeeping, and emitter-first render rejection. The landscape renderer
publishes outdoor landcells independently of surviving object meshes.
Terrain/entity streaming distance is
unchanged; an explicit Extended setting doubles only particle range. The
final two-client visual gate is still pending.
- **L.1c local attack receive path (implemented and live-gated 2026-07-15)** - **L.1c local attack receive path (implemented and live-gated 2026-07-15)**
local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted
funnel and action-stamp gate, so ACE's server-selected melee/missile action funnel and action-stamp gate, so ACE's server-selected melee/missile action

View file

@ -0,0 +1,176 @@
# Retail particle visibility and degradation-distance pseudocode
Date: 2026-07-17
Oracle: matching Sept-2013 `acclient.exe` + `acclient.pdb`
## Scope
This note records the retail mechanism that prevents distant or non-viewed
particle systems from consuming the full simulation and draw cost. It corrects
the older performance suggestion in `deepdives/r04-vfx-particles.md`: retail
does not merely hide distant particles while continuing to simulate them.
## Named retail functions
- `CPhysicsPart::GetMaxDegradeDistance` at `0x0050D510`
- `GfxObjDegradeInfo::get_max_degrade_distance` at `0x0051E2D0`
- `ParticleEmitter::SetInfo` at `0x0051CE90`
- `CPhysicsObj::ShouldDrawParticles` at `0x0050FE60`
- `ParticleEmitter::UpdateParticles` at `0x0051D180`
- `ParticleManager::UpdateParticles` at `0x0051B800`
The two short distance functions and the ambiguous x87 branch in
`UpdateParticles` were verified against instructions from the matching retail
binary, not inferred from the decompiler's generated types.
## Pseudocode
### `CPhysicsPart::GetMaxDegradeDistance`
```text
if part.gfx_object.degrades == null:
return 100.0
return part.gfx_object.degrades.get_max_degrade_distance()
```
The unit is the client's world unit (approximately one metre).
### `GfxObjDegradeInfo::get_max_degrade_distance`
```text
if num_degrades <= 2:
return degrades[0].max_dist
return degrades[num_degrades - 2].max_dist
```
`GfxObjInfo` is 20 bytes and `max_dist` is the field at offset 16. The
`num_degrades > 2` instruction reads `base + num_degrades * 20 - 24`, which is
exactly `degrades[num_degrades - 2].max_dist`.
### `ParticleEmitter::SetInfo`
```text
if hw_gfxobj_id cannot be loaded:
return false # no software-GfxObj substitution
create max_particles PhysicsPart instances from hw_gfxobj_id
degrade_distance = first_particle_part.GetMaxDegradeDistance()
allocate particle records
```
The distance belongs to the emitter's hardware particle GfxObj, not the owner
Setup and not the visual size of a particle.
### `CPhysicsObj::ShouldDrawParticles`
```text
if this is the examination object:
return true
if distance_from_viewer > emitter.degrade_distance:
return false
if cell == null:
return false
return cell.IsInView()
```
Equality passes: a particle owner at exactly the authored degradation distance
is still eligible. The x87 comparison also admits unordered input: a NaN
distance or authored maximum follows the comparison's pass branch. Negative,
NaN, and infinite authored `max_dist` values are not sanitized; the 100-unit
default applies only when the loaded hardware GfxObj has no degradation table.
### `ParticleEmitter::UpdateParticles`
```text
if owner.ShouldDrawParticles(degrade_distance):
if degraded_out:
degraded_out = false
SetNoDraw(false)
update every live particle
if parent-local:
read the parent's current frame
emit normally, observing total-particle and total-time limits
last_update_time = now
return whether emitter remains alive
if not degraded_out:
SetNoDraw(true)
degraded_out = true
last_update_time = now
if total_particles == 0 and total_seconds == 0:
# Infinite emitter. Retail rewrites each particle's birth/update time to
# now, freezing its age while degraded, and performs no normal emission.
for each live particle:
particle.birth_or_last_update_time = now
return true
# Finite emitter: retire expired particles and advance emission bookkeeping,
# but do not create/render a physical particle while degraded.
for each live particle:
particle.lifetime = now - particle.birth_time
if expired:
KillParticle(particle)
if not stopped:
if ShouldEmitParticle():
RecordParticleEmission() # num_particles++ and total_emitted++
StopEmitter() # conditional duration/count test
return true
return num_particles != 0
```
`StopEmitter` is a test despite its imperative name. It sets `stopped` only
after the authored total duration expires or `total_emitted` reaches the
authored total-particle limit. A finite emitter that is merely outside the view
therefore remains live and repeats this branch on later updates.
There is a counterintuitive retail lifetime edge case here that must not be
"repaired": `RecordParticleEmission` increments `num_particles` without
allocating a `PhysicsPart`. If that logical emission reaches the authored stop
condition while the effect is fully degraded, the already-stopped branch keeps
returning `num_particles != 0` even though there is no drawable particle to
retire. The emitter consequently remains owned until its physics object tears
the effect down. This follows directly from `RecordParticleEmission`
(`0x0051C870`), `UpdateParticles` (`0x0051D180`), and the manager's false-return
removal contract; the Core conformance test pins the quirk explicitly.
The x87 equality branch for `(total_particles == 0 && total_seconds == 0)` was
checked from `0x0051D1E1`: `fcomp 0`, `fnstsw ax`, `test ah,0x44`, `jp finite`.
For equality the parity jump is not taken, selecting the infinite-emitter
freeze path.
## Reference cross-checks
- ACE's `Physics/Particles/ParticleEmitter.cs` preserves the two degraded
update branches and birth-time reset, but deliberately assigns
`DegradeDistance = float.MaxValue`; it is therefore useful for control-flow
interpretation but not for the missing client distance rule.
- WorldBuilder/ACME's particle simulator confirms the DAT emitter fields and
integrators, but does not implement retail's live `CObjCell::IsInView` gate.
- The in-tree `GfxObjDegradeResolver` confirms the DatReaderWriter representation
of `DIDDegrade`, ordered `GfxObjInfo` entries, and `MaxDist`. `DatCollection`
remains the sole runtime DAT reader.
Installed-data fixture: Aerlinthe's dominant Swarm emitter `0x32000223`
(hardware GfxObj `0x01001358`) resolves to an authored maximum distance of
64 world units. Extended mode therefore raises that effect to 128 units; it
does not change landscape, scenery, entity, or fog distance.
## acdream integration mapping
- Core resolves the exact authored maximum distance into `EmitterDesc`.
- Core owns degraded simulation semantics and the no-per-particle fast path for
infinite emitters.
- App publishes the previous completed world-visibility product: retail PView
interior cells plus the landscape renderer's doorway-clipped outdoor
landcell set and camera position. The landscape-only fallback publishes the
same product without requiring an interior root. Outdoor dat stabs carry a
separate effect cell so their intentionally-null render parent is unchanged.
Login and portal-space frames publish an empty world view; explicit
examination and dedicated-pass policies are the only bypasses.
- The renderer filters an emitter before scanning its particle slots.
- Default mode uses authored retail distances. The optional Extended setting is
an explicit user-facing adaptation and is recorded in the divergence register.

View file

@ -638,21 +638,38 @@ totals ("Particles Rendered", "Particle Systems").
Retail did not implement a global particle LRU. Instead, each emitter Retail did not implement a global particle LRU. Instead, each emitter
enforced its `MaxParticles` hard cap and relied on distance-degrade: enforced its `MaxParticles` hard cap and relied on distance-degrade:
`PhysicsObj.ShouldDrawParticles(degradeDistance)` (ACE `CPhysicsObj::ShouldDrawParticles` (`0x0050FE60`):
`PhysicsObj.cs:1540-1544`):
```csharp ```text
public bool ShouldDrawParticles(float degradeDistance) { if ExaminationObject: return true
if (!ExaminationObject) return true; if CYpt > degradeDistance: return false
return !(CYpt > degradeDistance || CurCell == null); if CurCell == null: return false
} return CurCell.IsInView()
``` ```
Particles beyond a per-emitter `DegradeDistance` (ACE stubs it to The matching x87 `test ah,0x41` admits equal, less-than, and unordered
`float.MaxValue`) get `SetNoDraw(true)` — they keep simulating (so the comparisons. Authored negative/NaN/infinite values are preserved; 100 is used
server-side script advances) but aren't rendered. Real retail probably only when the hardware particle GfxObj has no degradation table.
used a per-`GfxObjDegradeInfo` value; for acdream we can set a sensible
default (50 m outdoor, 15 m indoor) and override via a PluginAPI hook. The exact distance is not guessed. `CPhysicsPart::GetMaxDegradeDistance`
(`0x0050D510`) returns 100 world units when the hardware particle GfxObj has
no degradation table. `GfxObjDegradeInfo::get_max_degrade_distance`
(`0x0051E2D0`) selects entry zero for one/two-entry tables and the
second-to-last entry for larger tables. `ParticleEmitter::SetInfo`
(`0x0051CE90`) stores that value from its first hardware particle part.
`ParticleEmitter::UpdateParticles` (`0x0051D180`) does more than hide the
result. While degraded, an infinite emitter performs no normal particle
integration or emission and freezes its live particle ages. A finite emitter
ages/kills existing particles, records at most one due emission without making
it drawable, and calls the conditional `StopEmitter` duration/count test. It
does not stop merely because it is degraded. If that counter-only emission
reaches the stop condition, retail's logical `num_particles` can remain
nonzero without a drawable `PhysicsPart`; the emitter then remains owned until
explicit physics-object teardown. ACE preserves those two update branches but stubs the
distance to `float.MaxValue`; retail's named code and matching x86 are the
oracle. Full pseudocode and the installed-data fixture are in
`docs/research/2026-07-17-retail-particle-visibility-pseudocode.md`.
--- ---
@ -798,11 +815,11 @@ RenderFrame:
7. UI 7. UI
``` ```
Simulation runs every frame regardless of visibility (matches retail's Simulation obeys retail's completed-view and authored-distance gate. Visible
`ShouldDrawParticles` behavior: hidden emitters still advance their emitters run their full update. Degraded infinite emitters freeze in place;
script). This costs a bit of CPU but keeps semantics correct — when an finite effects still age/retire and run their authored duration/count stop
ally buff expires behind a wall, it should still be gone when you see test, so a time-bounded ally buff effect does not become immortal behind a
them again. wall.
### Silk.NET shader sketch ### Silk.NET shader sketch

View file

@ -361,6 +361,7 @@ public sealed class GameWindow : IDisposable
private AcDream.Core.Vfx.EmitterDescRegistry? _emitterRegistry; private AcDream.Core.Vfx.EmitterDescRegistry? _emitterRegistry;
private AcDream.Core.Vfx.ParticleSystem? _particleSystem; private AcDream.Core.Vfx.ParticleSystem? _particleSystem;
private AcDream.Core.Vfx.ParticleHookSink? _particleSink; private AcDream.Core.Vfx.ParticleHookSink? _particleSink;
private readonly AcDream.App.Rendering.Vfx.ParticleVisibilityController _particleVisibility = new();
private readonly AcDream.App.Rendering.Vfx.EntityEffectPoseRegistry _effectPoses = new(); private readonly AcDream.App.Rendering.Vfx.EntityEffectPoseRegistry _effectPoses = new();
private AcDream.App.Rendering.Vfx.AnimationHookFrameQueue? _animationHookFrames; private AcDream.App.Rendering.Vfx.AnimationHookFrameQueue? _animationHookFrames;
// Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754) // Phase 6 — retail PhysicsScript runtime. Receives PlayScript (0xF754)
@ -2868,6 +2869,7 @@ public sealed class GameWindow : IDisposable
_itemInteractionController?.ClearBusy(); _itemInteractionController?.ClearBusy();
_selection.Reset(); _selection.Reset();
_pendingPostArrivalAction = null; _pendingPostArrivalAction = null;
_particleVisibility.Reset();
try try
{ {
_liveEntities?.Clear(); _liveEntities?.Clear();
@ -7544,6 +7546,7 @@ public sealed class GameWindow : IDisposable
Position = e.Position + worldOffset, Position = e.Position + worldOffset,
Rotation = e.Rotation, Rotation = e.Rotation,
MeshRefs = meshRefs, MeshRefs = meshRefs,
EffectCellId = e.EffectCellId,
IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag IsBuildingShell = e.IsBuildingShell, // Phase A8: preserve dat-level tag
BuildingShellAnchorCellId = e.BuildingShellAnchorCellId, BuildingShellAnchorCellId = e.BuildingShellAnchorCellId,
}; };
@ -7870,6 +7873,10 @@ public sealed class GameWindow : IDisposable
Rotation = spawn.Rotation, Rotation = spawn.Rotation,
MeshRefs = meshRefs, MeshRefs = meshRefs,
Scale = spawn.Scale, Scale = spawn.Scale,
EffectCellId = AcDream.Core.Physics.TerrainSurface.ComputeOutdoorCellId(
lb.LandblockId,
localX,
localY),
}; };
if (sceneryBounds.TryGet(out var scbMin, out var scbMax)) if (sceneryBounds.TryGet(out var scbMin, out var scbMax))
hydrated.SetLocalBounds(scbMin, scbMax); hydrated.SetLocalBounds(scbMin, scbMax);
@ -9502,6 +9509,8 @@ public sealed class GameWindow : IDisposable
// normal SmartBox viewport and UIElement_Viewport's portal // normal SmartBox viewport and UIElement_Viewport's portal
// CreatureMode; it does not redraw the world behind portal space. // CreatureMode; it does not redraw the world behind portal space.
bool portalViewportVisible = _portalTunnel?.IsVisible == true; bool portalViewportVisible = _portalTunnel?.IsVisible == true;
if (portalViewportVisible)
_particleVisibility.Reset();
// Phase G.1: set the clear color from the current sky's fog // Phase G.1: set the clear color from the current sky's fog
// tint so the horizon band continues naturally past the // tint so the horizon band continues naturally past the
@ -9623,6 +9632,10 @@ public sealed class GameWindow : IDisposable
// and by the sky renderer (for the camera-centered sky dome). // and by the sky renderer (for the camera-centered sky dome).
System.Numerics.Matrix4x4.Invert(camera.View, out var invView); System.Numerics.Matrix4x4.Invert(camera.View, out var invView);
var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43); var camPos = new System.Numerics.Vector3(invView.M41, invView.M42, invView.M43);
_particleVisibility.BeginFrame(camPos);
_terrain?.BeginVisibilityFrame();
if (!IsLiveModeWaitingForLogin)
_particleVisibility.UseWorldView();
// L.0 Audio tab: push the SettingsVM's live AudioDraft into the // L.0 Audio tab: push the SettingsVM's live AudioDraft into the
// engine each frame, so volume sliders preview audibly while // engine each frame, so volume sliders preview audibly while
@ -10205,6 +10218,7 @@ public sealed class GameWindow : IDisposable
clipAssembly = pviewResult.ClipAssembly; clipAssembly = pviewResult.ClipAssembly;
envCellShellFilter = pviewResult.DrawableCells; envCellShellFilter = pviewResult.DrawableCells;
_interiorPartition = pviewResult.Partition; _interiorPartition = pviewResult.Partition;
_particleVisibility.MarkVisibleCells(pviewResult.DrawableCells);
// A7.L1: snapshot this frame's drawn cell set for NEXT frame's light- // A7.L1: snapshot this frame's drawn cell set for NEXT frame's light-
// pool scoping. DrawableCells is the renderer's own reused scratch set // pool scoping. DrawableCells is the renderer's own reused scratch set
@ -10517,7 +10531,10 @@ public sealed class GameWindow : IDisposable
// below. Sky has already drawn before this label so the // below. Sky has already drawn before this label so the
// pre-login screen shows a live, correctly-tinted sky and // pre-login screen shows a live, correctly-tinted sky and
// nothing else. // nothing else.
SkipWorldGeometry: ; SkipWorldGeometry:
if (_terrain is not null)
_particleVisibility.MarkVisibleCells(_terrain.VisibleCellIds);
_particleVisibility.CompleteFrame();
} }
// Retail gmSmartBoxUI swaps the world SmartBox viewport for a // Retail gmSmartBoxUI swaps the world SmartBox viewport for a
@ -10734,6 +10751,17 @@ public sealed class GameWindow : IDisposable
_particleSink?.RefreshAttachedEmitters(); _particleSink?.RefreshAttachedEmitters();
_liveEntityLights?.Refresh(); _liveEntityLights?.Refresh();
if (_particleSystem is not null)
{
var particleRange = _settingsVm?.DisplayDraft.ParticleRange
?? _persistedDisplay.ParticleRange;
float rangeMultiplier = particleRange
== AcDream.UI.Abstractions.Panels.Settings.ParticleRange.Extended
? AcDream.App.Rendering.Vfx.ParticleVisibilityController.ExtendedRangeMultiplier
: 1f;
_particleVisibility.Apply(_particleSystem, rangeMultiplier);
}
// Retail CPhysicsObj::UpdateObjectInternal (0x005156B0) advances // Retail CPhysicsObj::UpdateObjectInternal (0x005156B0) advances
// ParticleManager before ScriptManager. A particle created by a PES // ParticleManager before ScriptManager. A particle created by a PES
// hook therefore begins simulation on the following object frame. // hook therefore begins simulation on the following object frame.
@ -11527,7 +11555,12 @@ public sealed class GameWindow : IDisposable
EnableClipDistances(); EnableClipDistances();
_terrainCpuStopwatch.Restart(); _terrainCpuStopwatch.Restart();
_terrain?.Draw(camera, frustum, neverCullLandblockId: playerLb); _terrain?.Draw(
camera,
frustum,
neverCullLandblockId: playerLb,
clipPlanes: slice.Planes,
ndcClipAabb: slice.NdcAabb);
_terrainCpuStopwatch.Stop(); _terrainCpuStopwatch.Stop();
_terrainCpuSamples[_terrainCpuSampleCursor] = _terrainCpuSamples[_terrainCpuSampleCursor] =
(long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds * 100.0); (long)(_terrainCpuStopwatch.Elapsed.TotalMicroseconds * 100.0);

View file

@ -395,61 +395,66 @@ public sealed unsafe class ParticleRenderer : IDisposable
_meshDrawListScratch.Clear(); _meshDrawListScratch.Clear();
_submissionScratch.Clear(); _submissionScratch.Clear();
int sequence = 0; int sequence = 0;
foreach (var (em, idx) in _particles.EnumerateLive()) foreach (var em in _particles.EnumerateEmitters())
{ {
if (!em.PresentationVisible) if (!em.PresentationVisible || !em.ViewEligible)
continue; continue;
if (em.RenderPass != renderPass) if (em.RenderPass != renderPass)
continue; continue;
if (emitterFilter is not null && !emitterFilter(em)) if (emitterFilter is not null && !emitterFilter(em))
continue; continue;
ref var p = ref em.Particles[idx]; for (int idx = 0; idx < em.Particles.Length; 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);
uint gfxObjId = em.Desc.HwGfxObjId != 0 ? em.Desc.HwGfxObjId : em.Desc.GfxObjId;
if (gfxObjId != 0
&& ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh
&& TryAppendMeshDraws(em, p, gfxObjId, distSq, ref sequence))
{ {
continue; ref var p = ref em.Particles[idx];
} if (!p.Alive)
continue;
// `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);
uint gfxObjId = em.Desc.HwGfxObjId != 0 ? em.Desc.HwGfxObjId : em.Desc.GfxObjId;
if (gfxObjId != 0
&& ResolveGeometryKind(gfxObjId) == RetailParticleGeometryKind.FullMesh
&& TryAppendMeshDraws(em, p, gfxObjId, distSq, ref sequence))
{
continue;
}
var gfxInfo = ResolveParticleGfxInfo(em.Desc); var gfxInfo = ResolveParticleGfxInfo(em.Desc);
uint texture = gfxInfo.TextureHandle; uint texture = gfxInfo.TextureHandle;
bool useTexture = texture != 0; bool useTexture = texture != 0;
bool additive = gfxInfo.HasMaterial bool additive = gfxInfo.HasMaterial
? gfxInfo.Additive ? gfxInfo.Additive
: (em.Desc.Flags & EmitterFlags.Additive) != 0; : (em.Desc.Flags & EmitterFlags.Additive) != 0;
var key = new BatchKey(texture, useTexture, additive); var key = new BatchKey(texture, useTexture, additive);
Vector3 axisX; Vector3 axisX;
Vector3 axisY; Vector3 axisY;
if (gfxInfo.IsBillboard) if (gfxInfo.IsBillboard)
{ {
pos += Vector3.UnitZ * (gfxInfo.CenterOffset.Z * p.Size); pos += Vector3.UnitZ * (gfxInfo.CenterOffset.Z * p.Size);
axisX = cameraRight * (gfxInfo.Size.X * p.Size); axisX = cameraRight * (gfxInfo.Size.X * p.Size);
axisY = cameraUp * (gfxInfo.Size.Y * p.Size); axisY = cameraUp * (gfxInfo.Size.Y * p.Size);
} }
else else
{ {
Quaternion orientation = ParticleOrientation(em, p); Quaternion orientation = ParticleOrientation(em, p);
pos += Vector3.Transform(gfxInfo.CenterOffset * p.Size, orientation); pos += Vector3.Transform(gfxInfo.CenterOffset * p.Size, orientation);
axisX = Vector3.Transform(gfxInfo.AxisX, orientation) * (gfxInfo.Size.X * p.Size); axisX = Vector3.Transform(gfxInfo.AxisX, orientation) * (gfxInfo.Size.X * p.Size);
axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size); axisY = Vector3.Transform(gfxInfo.AxisY, orientation) * (gfxInfo.Size.Y * p.Size);
} }
int drawIndex = draws.Count; int drawIndex = draws.Count;
draws.Add(new ParticleDraw(key, new ParticleInstance(pos, axisX, axisY, p.ColorArgb, distSq))); draws.Add(new ParticleDraw(key, new ParticleInstance(pos, axisX, axisY, p.ColorArgb, distSq)));
_submissionScratch.Add(new ParticleSubmission( _submissionScratch.Add(new ParticleSubmission(
ParticleSubmissionKind.Billboard, ParticleSubmissionKind.Billboard,
drawIndex, drawIndex,
distSq, distSq,
sequence++)); sequence++));
}
} }
} }

View file

@ -69,12 +69,21 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// Reusable per-frame buffers. // Reusable per-frame buffers.
private readonly List<int> _visibleSlots = new(); private readonly List<int> _visibleSlots = new();
private readonly HashSet<uint> _visibleCellIds = new();
private DrawElementsIndirectCommand[] _deicScratch = Array.Empty<DrawElementsIndirectCommand>(); private DrawElementsIndirectCommand[] _deicScratch = Array.Empty<DrawElementsIndirectCommand>();
// Diag. // Diag.
public int LoadedSlots => _alloc.LoadedCount; public int LoadedSlots => _alloc.LoadedCount;
public int VisibleSlots => _visibleSlots.Count; public int VisibleSlots => _visibleSlots.Count;
public int CapacitySlots => _alloc.Capacity; public int CapacitySlots => _alloc.Capacity;
/// <summary>
/// Outdoor landcells admitted by the current landscape view. The set is
/// accumulated across doorway landscape slices and consumed after the
/// completed render frame by particle visibility.
/// </summary>
internal HashSet<uint> VisibleCellIds => _visibleCellIds;
public void BeginVisibilityFrame() => _visibleCellIds.Clear();
public TerrainModernRenderer( public TerrainModernRenderer(
GL gl, GL gl,
@ -208,10 +217,17 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
// No GPU clear: the per-frame DEIC array won't reference this slot. // No GPU clear: the per-frame DEIC array won't reference this slot.
} }
public void Draw(ICamera camera, FrustumPlanes? frustum = null, uint? neverCullLandblockId = null) public void Draw(
ICamera camera,
FrustumPlanes? frustum = null,
uint? neverCullLandblockId = null,
ReadOnlySpan<Vector4> clipPlanes = default,
Vector4? ndcClipAabb = null)
{ {
if (_alloc.LoadedCount == 0) return; if (_alloc.LoadedCount == 0) return;
Matrix4x4 viewProjection = camera.View * camera.Projection;
// Build visible slot list with per-slot frustum cull. // Build visible slot list with per-slot frustum cull.
_visibleSlots.Clear(); _visibleSlots.Clear();
for (int slot = 0; slot < _slots.Length; slot++) for (int slot = 0; slot < _slots.Length; slot++)
@ -224,6 +240,16 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
continue; continue;
} }
_visibleSlots.Add(slot); _visibleSlots.Add(slot);
CollectVisibleCells(
_visibleCellIds,
data.LandblockId,
data.WorldOrigin,
data.AabbMin.Z,
data.AabbMax.Z,
frustum,
viewProjection,
clipPlanes,
ndcClipAabb);
} }
if (_visibleSlots.Count == 0) return; if (_visibleSlots.Count == 0) return;
@ -436,6 +462,130 @@ public sealed unsafe class TerrainModernRenderer : IDisposable
_gl.BindVertexArray(0); _gl.BindVertexArray(0);
} }
internal static void CollectVisibleCells(
HashSet<uint> destination,
uint landblockId,
Vector3 worldOrigin,
float zMin,
float zMax,
FrustumPlanes? frustum,
Matrix4x4 viewProjection,
ReadOnlySpan<Vector4> clipPlanes,
Vector4? ndcClipAabb = null)
{
ArgumentNullException.ThrowIfNull(destination);
const float cellSize = AcDream.Core.Physics.TerrainSurface.CellSize;
const int cellsPerSide = AcDream.Core.Physics.TerrainSurface.CellsPerSide;
uint prefix = landblockId & 0xFFFF0000u;
for (int cellX = 0; cellX < cellsPerSide; cellX++)
{
float minX = worldOrigin.X + cellX * cellSize;
float maxX = minX + cellSize;
for (int cellY = 0; cellY < cellsPerSide; cellY++)
{
float minY = worldOrigin.Y + cellY * cellSize;
float maxY = minY + cellSize;
var cellMin = new Vector3(minX, minY, zMin);
var cellMax = new Vector3(maxX, maxY, zMax);
if (frustum is not null
&& !FrustumCuller.IsAabbVisible(frustum.Value, cellMin, cellMax))
{
continue;
}
// Retail publishes landcell in_view from the clipped landscape
// view, not merely from the camera frustum. The modern renderer
// expresses each doorway slice as homogeneous clip-space planes
// plus its scissor AABB; use both products here so particle
// simulation follows the same visible terrain slice as the GPU.
if (!IsAabbVisibleThroughClipRegion(
cellMin,
cellMax,
viewProjection,
clipPlanes,
ndcClipAabb))
{
continue;
}
uint low = AcDream.Core.Physics.TerrainSurface.ComputeOutdoorCellLowId(
cellX * cellSize,
cellY * cellSize);
destination.Add(prefix | low);
}
}
}
private static bool IsAabbVisibleThroughClipRegion(
Vector3 min,
Vector3 max,
Matrix4x4 viewProjection,
ReadOnlySpan<Vector4> clipPlanes,
Vector4? ndcClipAabb)
{
Vector4 aabb = ndcClipAabb.GetValueOrDefault();
bool hasScissorConstraint = ndcClipAabb.HasValue
&& (aabb.X > -1f || aabb.Y > -1f || aabb.Z < 1f || aabb.W < 1f);
if (clipPlanes.IsEmpty && !hasScissorConstraint)
return true;
Span<Vector4> clipCorners = stackalloc Vector4[8];
for (int corner = 0; corner < clipCorners.Length; corner++)
{
var world = new Vector4(
(corner & 1) == 0 ? min.X : max.X,
(corner & 2) == 0 ? min.Y : max.Y,
(corner & 4) == 0 ? min.Z : max.Z,
1f);
clipCorners[corner] = Vector4.Transform(world, viewProjection);
}
for (int planeIndex = 0; planeIndex < clipPlanes.Length; planeIndex++)
{
if (IsAabbOutsideHomogeneousPlane(clipCorners, clipPlanes[planeIndex]))
{
return false;
}
}
if (!hasScissorConstraint)
return true;
Span<Vector4> scissorPlanes = stackalloc Vector4[4]
{
new( 1f, 0f, 0f, -aabb.X),
new(-1f, 0f, 0f, aabb.Z),
new( 0f, 1f, 0f, -aabb.Y),
new( 0f, -1f, 0f, aabb.W),
};
for (int planeIndex = 0; planeIndex < scissorPlanes.Length; planeIndex++)
{
if (IsAabbOutsideHomogeneousPlane(clipCorners, scissorPlanes[planeIndex]))
{
return false;
}
}
return true;
}
private static bool IsAabbOutsideHomogeneousPlane(
ReadOnlySpan<Vector4> clipCorners,
Vector4 plane)
{
// A linear half-space reaches its maximum over the transformed AABB at
// one of the eight corners. If every corner is negative, no point in
// the cell box can survive this GPU clip plane.
for (int corner = 0; corner < clipCorners.Length; corner++)
{
if (Vector4.Dot(plane, clipCorners[corner]) >= 0f)
return false;
}
return true;
}
private void EnsureCapacity(int newCapacity) private void EnsureCapacity(int newCapacity)
{ {
if (newCapacity <= _alloc.Capacity) return; if (newCapacity <= _alloc.Capacity) return;

View file

@ -45,7 +45,7 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE
Matrix4x4.CreateFromQuaternion(entity.Rotation) Matrix4x4.CreateFromQuaternion(entity.Rotation)
* Matrix4x4.CreateTranslation(entity.Position), * Matrix4x4.CreateTranslation(entity.Position),
partLocal, partLocal,
entity.ParentCellId ?? 0u, entity.EffectCellId ?? entity.ParentCellId ?? 0u,
availability); availability);
} }
@ -67,7 +67,7 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE
} }
record.RootWorld = rootWorld; record.RootWorld = rootWorld;
record.CellId = entity.ParentCellId ?? 0u; record.CellId = entity.EffectCellId ?? entity.ParentCellId ?? 0u;
if (entity.IndexedPartTransforms.Count > 0) if (entity.IndexedPartTransforms.Count > 0)
{ {
CopyParts( CopyParts(
@ -119,7 +119,7 @@ public sealed class EntityEffectPoseRegistry : IEntityEffectPoseSource, IEntityE
return false; return false;
record.RootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation) record.RootWorld = Matrix4x4.CreateFromQuaternion(entity.Rotation)
* Matrix4x4.CreateTranslation(entity.Position); * Matrix4x4.CreateTranslation(entity.Position);
record.CellId = entity.ParentCellId ?? 0u; record.CellId = entity.EffectCellId ?? entity.ParentCellId ?? 0u;
return true; return true;
} }

View file

@ -0,0 +1,93 @@
using System.Numerics;
using AcDream.Core.Vfx;
namespace AcDream.App.Rendering.Vfx;
/// <summary>
/// Bridges the retained retail PView result into the next physics update's
/// <c>CObjCell::IsInView</c> particle gate. The controller owns only immutable
/// frame meaning: one completed viewer position plus the AC cells admitted by
/// that completed view. It neither creates emitters nor performs rendering.
/// </summary>
public sealed class ParticleVisibilityController
{
public const float ExtendedRangeMultiplier = 2f;
private readonly HashSet<uint> _buildingCellIds = new();
private readonly HashSet<uint> _completedCellIds = new();
private Vector3 _buildingViewerPosition;
private Vector3 _completedViewerPosition;
private bool _frameOpen;
private bool _frameUsesWorldView;
private bool _hasCompletedWorldView;
public void BeginFrame(Vector3 viewerPosition)
{
_buildingCellIds.Clear();
_buildingViewerPosition = viewerPosition;
_frameUsesWorldView = false;
_frameOpen = true;
}
/// <summary>
/// Declares that this frame has an authoritative world-visibility product.
/// That product can come from the unified retail PView or from the outdoor
/// landscape fallback. Login and portal-space frames deliberately omit it;
/// dedicated pass and examination emitters carry explicit bypass policies.
/// </summary>
public void UseWorldView()
{
if (_frameOpen)
_frameUsesWorldView = true;
}
public void MarkVisibleCells(HashSet<uint> cellIds)
{
ArgumentNullException.ThrowIfNull(cellIds);
if (!_frameOpen || !_frameUsesWorldView)
return;
_buildingCellIds.UnionWith(cellIds);
}
public void CompleteFrame()
{
if (!_frameOpen)
return;
_frameOpen = false;
if (!_frameUsesWorldView)
{
_completedCellIds.Clear();
_completedViewerPosition = _buildingViewerPosition;
_hasCompletedWorldView = false;
return;
}
_completedCellIds.Clear();
_completedCellIds.UnionWith(_buildingCellIds);
_completedViewerPosition = _buildingViewerPosition;
_hasCompletedWorldView = true;
}
public void Apply(ParticleSystem particles, float rangeMultiplier)
{
ArgumentNullException.ThrowIfNull(particles);
particles.ApplyRetailView(
_completedViewerPosition,
_completedCellIds,
_hasCompletedWorldView,
rangeMultiplier);
}
public void Reset()
{
_buildingCellIds.Clear();
_completedCellIds.Clear();
_frameOpen = false;
_frameUsesWorldView = false;
_hasCompletedWorldView = false;
_buildingViewerPosition = default;
_completedViewerPosition = default;
}
}

View file

@ -23,8 +23,8 @@ public readonly record struct TerrainSurfacePolygon(
public sealed class TerrainSurface public sealed class TerrainSurface
{ {
private const int HeightmapSide = 9; private const int HeightmapSide = 9;
private const float CellSize = 24f; public const float CellSize = 24f;
private const int CellsPerSide = 8; // 192 / 24 public const int CellsPerSide = 8; // 192 / 24
private readonly float[,] _z; // pre-resolved heights [x, y] private readonly float[,] _z; // pre-resolved heights [x, y]
private readonly bool[,] _cornerIsWater; // per-VERTEX water flag [x, y] — SurfChar[(type >> 2) & 0x1F] private readonly bool[,] _cornerIsWater; // per-VERTEX water flag [x, y] — SurfChar[(type >> 2) & 0x1F]
@ -508,12 +508,23 @@ public sealed class TerrainSurface
/// Compute the outdoor cell ID for the given landblock-local position. /// Compute the outdoor cell ID for the given landblock-local position.
/// </summary> /// </summary>
public uint ComputeOutdoorCellId(float localX, float localY) public uint ComputeOutdoorCellId(float localX, float localY)
=> ComputeOutdoorCellLowId(localX, localY);
/// <summary>
/// Computes the low 16-bit outdoor landcell index for a landblock-local
/// position using retail's X-major 8x8 cell ordering.
/// </summary>
public static uint ComputeOutdoorCellLowId(float localX, float localY)
{ {
int cx = Math.Clamp((int)(localX / CellSize), 0, CellsPerSide - 1); int cx = Math.Clamp((int)(localX / CellSize), 0, CellsPerSide - 1);
int cy = Math.Clamp((int)(localY / CellSize), 0, CellsPerSide - 1); int cy = Math.Clamp((int)(localY / CellSize), 0, CellsPerSide - 1);
return (uint)(1 + cx * CellsPerSide + cy); return (uint)(1 + cx * CellsPerSide + cy);
} }
/// <summary>Composes a full outdoor landcell ID from a landblock ID.</summary>
public static uint ComputeOutdoorCellId(uint landblockId, float localX, float localY)
=> (landblockId & 0xFFFF0000u) | ComputeOutdoorCellLowId(localX, localY);
/// <summary> /// <summary>
/// AC2D's FSplitNESW render formula. Returns true for SW→NE diagonal. /// AC2D's FSplitNESW render formula. Returns true for SW→NE diagonal.
/// Uses global cell coordinates (landblockX*8+cellX, landblockY*8+cellY). /// Uses global cell coordinates (landblockX*8+cellX, landblockY*8+cellY).

View file

@ -2,9 +2,13 @@ using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Numerics; using System.Numerics;
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.Lib.IO;
using DatParticleEmitter = DatReaderWriter.DBObjs.ParticleEmitter; using DatParticleEmitter = DatReaderWriter.DBObjs.ParticleEmitter;
using DatGfxObj = DatReaderWriter.DBObjs.GfxObj;
using DatGfxObjDegradeInfo = DatReaderWriter.DBObjs.GfxObjDegradeInfo;
using DatEmitterType = DatReaderWriter.Enums.EmitterType; using DatEmitterType = DatReaderWriter.Enums.EmitterType;
using DatParticleType = DatReaderWriter.Enums.ParticleType; using DatParticleType = DatReaderWriter.Enums.ParticleType;
using DatGfxObjFlags = DatReaderWriter.Enums.GfxObjFlags;
namespace AcDream.Core.Vfx; namespace AcDream.Core.Vfx;
@ -15,6 +19,7 @@ namespace AcDream.Core.Vfx;
public sealed class EmitterDescRegistry public sealed class EmitterDescRegistry
{ {
private readonly Func<uint, DatParticleEmitter?>? _resolver; private readonly Func<uint, DatParticleEmitter?>? _resolver;
private readonly Func<DatParticleEmitter, float?>? _degradeDistanceResolver;
private readonly ConcurrentDictionary<uint, EmitterDesc> _byId = new(); private readonly ConcurrentDictionary<uint, EmitterDesc> _byId = new();
public EmitterDescRegistry() public EmitterDescRegistry()
@ -23,8 +28,10 @@ public sealed class EmitterDescRegistry
} }
public EmitterDescRegistry(DatCollection dats) public EmitterDescRegistry(DatCollection dats)
: this(id => SafeGet(dats, id))
{ {
ArgumentNullException.ThrowIfNull(dats);
_resolver = id => SafeGet<DatParticleEmitter>(dats, id);
_degradeDistanceResolver = emitter => ResolveMaxDegradeDistance(dats, emitter);
} }
public EmitterDescRegistry(Func<uint, DatParticleEmitter?>? resolver) public EmitterDescRegistry(Func<uint, DatParticleEmitter?>? resolver)
@ -55,7 +62,18 @@ public sealed class EmitterDescRegistry
var dat = _resolver(emitterId); var dat = _resolver(emitterId);
if (dat is not null) if (dat is not null)
{ {
desc = FromDat(emitterId, dat); float? resolvedDistance = _degradeDistanceResolver?.Invoke(dat);
if (_degradeDistanceResolver is not null && !resolvedDistance.HasValue)
{
// ParticleEmitter::SetInfo (0x0051CE90) fails when the
// authored hardware GfxObj cannot be loaded. It never
// substitutes the software GfxObj.
desc = null!;
return false;
}
float maxDegradeDistance = resolvedDistance
?? RetailParticleDegradeDistance.DefaultDistance;
desc = FromDat(emitterId, dat, maxDegradeDistance);
_byId[emitterId] = desc; _byId[emitterId] = desc;
return true; return true;
} }
@ -66,7 +84,10 @@ public sealed class EmitterDescRegistry
public int Count => _byId.Count; public int Count => _byId.Count;
public static EmitterDesc FromDat(uint emitterId, DatParticleEmitter dat) public static EmitterDesc FromDat(
uint emitterId,
DatParticleEmitter dat,
float maxDegradeDistance = RetailParticleDegradeDistance.DefaultDistance)
{ {
ArgumentNullException.ThrowIfNull(dat); ArgumentNullException.ThrowIfNull(dat);
@ -90,6 +111,7 @@ public sealed class EmitterDescRegistry
return new EmitterDesc return new EmitterDesc
{ {
MaxDegradeDistance = maxDegradeDistance,
DatId = emitterId, DatId = emitterId,
Type = MapParticleType(dat.ParticleType), Type = MapParticleType(dat.ParticleType),
EmitterKind = MapEmitterKind(dat.EmitterType), EmitterKind = MapEmitterKind(dat.EmitterType),
@ -132,13 +154,37 @@ public sealed class EmitterDescRegistry
}; };
} }
private static DatParticleEmitter? SafeGet(DatCollection dats, uint id) private static float? ResolveMaxDegradeDistance(
DatCollection dats,
DatParticleEmitter emitter)
{
uint gfxObjId = GetRetailHardwareGfxObjId(emitter);
if (gfxObjId == 0)
return null;
DatGfxObj? gfx = SafeGet<DatGfxObj>(dats, gfxObjId);
if (gfx is null)
return null;
if (!gfx.Flags.HasFlag(DatGfxObjFlags.HasDIDDegrade)
|| gfx.DIDDegrade == 0)
{
return RetailParticleDegradeDistance.DefaultDistance;
}
DatGfxObjDegradeInfo? degrade = SafeGet<DatGfxObjDegradeInfo>(dats, gfx.DIDDegrade);
return RetailParticleDegradeDistance.FromEntries(degrade?.Degrades);
}
internal static uint GetRetailHardwareGfxObjId(DatParticleEmitter emitter)
=> emitter.HwGfxObjId.DataId;
private static T? SafeGet<T>(DatCollection dats, uint id) where T : class, IDBObj
{ {
if (dats is null) if (dats is null)
return null; return null;
try try
{ {
return dats.Get<DatParticleEmitter>(id); return dats.Get<T>(id);
} }
catch catch
{ {
@ -170,3 +216,24 @@ public sealed class EmitterDescRegistry
_ => ParticleType.Unknown, _ => ParticleType.Unknown,
}; };
} }
/// <summary>
/// Exact retail maximum-distance selection for a particle GfxObj.
/// <c>CPhysicsPart::GetMaxDegradeDistance</c> (0x0050D510) supplies the
/// 100-unit default; <c>GfxObjDegradeInfo::get_max_degrade_distance</c>
/// (0x0051E2D0) selects entry zero for one/two-entry tables and the
/// second-to-last entry for larger tables.
/// </summary>
public static class RetailParticleDegradeDistance
{
public const float DefaultDistance = 100f;
public static float FromEntries(IReadOnlyList<DatReaderWriter.Types.GfxObjInfo>? entries)
{
if (entries is null || entries.Count == 0)
return DefaultDistance;
int index = entries.Count <= 2 ? 0 : entries.Count - 2;
return entries[index].MaxDist;
}
}

View file

@ -28,15 +28,18 @@ public sealed class ParticleHookSink : IAnimationHookSink
{ {
private readonly ParticleSystem _system; private readonly ParticleSystem _system;
private readonly IEntityEffectPoseSource _poses; private readonly IEntityEffectPoseSource _poses;
private readonly IEntityEffectCellSource? _cells;
private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new(); private readonly ConcurrentDictionary<(uint EntityId, uint EmitterId), int> _handlesByKey = new();
private readonly ConcurrentDictionary<uint, ConcurrentDictionary<int, byte>> _handlesByEntity = new(); private readonly ConcurrentDictionary<uint, ConcurrentDictionary<int, byte>> _handlesByEntity = new();
private readonly ConcurrentDictionary<int, EmitterBinding> _bindingsByHandle = new(); private readonly ConcurrentDictionary<int, EmitterBinding> _bindingsByHandle = new();
private readonly ConcurrentDictionary<uint, ParticleRenderPass> _renderPassByEntity = new(); private readonly ConcurrentDictionary<uint, ParticleRenderPass> _renderPassByEntity = new();
private readonly ConcurrentDictionary<uint, byte> _examinationOwners = new();
private readonly ConcurrentDictionary<uint, byte> _hiddenPresentationOwners = new(); private readonly ConcurrentDictionary<uint, byte> _hiddenPresentationOwners = new();
public ParticleHookSink(ParticleSystem system, IEntityEffectPoseSource poses) public ParticleHookSink(ParticleSystem system, IEntityEffectPoseSource poses)
{ {
_system = system ?? throw new ArgumentNullException(nameof(system)); _system = system ?? throw new ArgumentNullException(nameof(system));
_poses = poses ?? throw new ArgumentNullException(nameof(poses)); _poses = poses ?? throw new ArgumentNullException(nameof(poses));
_cells = poses as IEntityEffectCellSource;
_system.EmitterDied += OnEmitterDied; _system.EmitterDied += OnEmitterDied;
} }
@ -52,6 +55,7 @@ public sealed class ParticleHookSink : IAnimationHookSink
public int LogicalEmitterCount => _handlesByKey.Count; public int LogicalEmitterCount => _handlesByKey.Count;
public int TrackedOwnerCount => _handlesByEntity.Count; public int TrackedOwnerCount => _handlesByEntity.Count;
public int RenderPassOwnerCount => _renderPassByEntity.Count; public int RenderPassOwnerCount => _renderPassByEntity.Count;
public int ExaminationOwnerCount => _examinationOwners.Count;
public int HiddenPresentationOwnerCount => _hiddenPresentationOwners.Count; public int HiddenPresentationOwnerCount => _hiddenPresentationOwners.Count;
private void OnEmitterDied(int handle) private void OnEmitterDied(int handle)
@ -115,11 +119,32 @@ public sealed class ParticleHookSink : IAnimationHookSink
} }
} }
public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass) => public void SetEntityRenderPass(uint entityId, ParticleRenderPass renderPass)
{
_renderPassByEntity[entityId] = renderPass; _renderPassByEntity[entityId] = renderPass;
RefreshOwnerVisibilityPolicy(entityId);
}
public void ClearEntityRenderPass(uint entityId) => public void ClearEntityRenderPass(uint entityId)
{
_renderPassByEntity.TryRemove(entityId, out _); _renderPassByEntity.TryRemove(entityId, out _);
RefreshOwnerVisibilityPolicy(entityId);
}
/// <summary>
/// Mirrors retail <c>CPhysicsObj::m_bExaminationObject</c>. Examination
/// previews bypass world distance and cell visibility without conflating
/// that policy with attachment identity.
/// </summary>
public void SetEntityExaminationObject(uint entityId, bool isExaminationObject)
{
if (isExaminationObject)
_examinationOwners[entityId] = 0;
else
_examinationOwners.TryRemove(entityId, out _);
RefreshOwnerVisibilityPolicy(entityId);
}
/// <summary> /// <summary>
/// Mirrors live cell membership without ending emitter lifetime. Retail /// Mirrors live cell membership without ending emitter lifetime. Retail
@ -158,9 +183,18 @@ public sealed class ParticleHookSink : IAnimationHookSink
bool presentationVisible = bool presentationVisible =
!_hiddenPresentationOwners.ContainsKey(binding.OwnerLocalId); !_hiddenPresentationOwners.ContainsKey(binding.OwnerLocalId);
if (TryResolveAnchor(binding.OwnerLocalId, binding.PartIndex, if (TryResolveAnchor(binding.OwnerLocalId, binding.PartIndex,
binding.HookOffsetOrigin, out Vector3 anchor, out Quaternion rotation)) binding.HookOffsetOrigin,
out Vector3 anchor,
out Quaternion rotation,
out Vector3 ownerPosition))
{ {
_system.UpdateEmitterAnchor(handle, anchor, rotation); _system.UpdateEmitterAnchor(handle, anchor, rotation);
_system.UpdateEmitterOwnerPosition(handle, ownerPosition);
_system.UpdateEmitterOwnerCell(
handle,
_cells is not null && _cells.TryGetCellId(binding.OwnerLocalId, out uint cellId)
? cellId
: 0u);
// Keep the anchor current while spatially paused; re-entry // Keep the anchor current while spatially paused; re-entry
// resumes at the authoritative pose without generating the // resumes at the authoritative pose without generating the
// absent interval's time- or distance-driven emissions. // absent interval's time- or distance-driven emissions.
@ -193,6 +227,7 @@ public sealed class ParticleHookSink : IAnimationHookSink
_handlesByKey.TryRemove(key, out _); _handlesByKey.TryRemove(key, out _);
} }
ClearEntityRenderPass(entityId); ClearEntityRenderPass(entityId);
_examinationOwners.TryRemove(entityId, out _);
_hiddenPresentationOwners.TryRemove(entityId, out _); _hiddenPresentationOwners.TryRemove(entityId, out _);
} }
@ -236,7 +271,9 @@ public sealed class ParticleHookSink : IAnimationHookSink
Vector3 offsetOrigin = offset?.Origin ?? Vector3.Zero; Vector3 offsetOrigin = offset?.Origin ?? Vector3.Zero;
Quaternion offsetOrientation = offset?.Orientation ?? Quaternion.Identity; Quaternion offsetOrientation = offset?.Orientation ?? Quaternion.Identity;
if (!TryResolveAnchor(ownerLocalId, partIndex, offsetOrigin, if (!TryResolveAnchor(ownerLocalId, partIndex, offsetOrigin,
out Vector3 anchor, out Quaternion rotation)) out Vector3 anchor,
out Quaternion rotation,
out Vector3 ownerPosition))
{ {
DiagnosticSink?.Invoke( DiagnosticSink?.Invoke(
$"No live effect pose for owner 0x{ownerLocalId:X8}, part {partIndex}; " + $"No live effect pose for owner 0x{ownerLocalId:X8}, part {partIndex}; " +
@ -247,6 +284,7 @@ public sealed class ParticleHookSink : IAnimationHookSink
ParticleRenderPass renderPass = _renderPassByEntity.TryGetValue(ownerLocalId, out var pass) ParticleRenderPass renderPass = _renderPassByEntity.TryGetValue(ownerLocalId, out var pass)
? pass ? pass
: ParticleRenderPass.Scene; : ParticleRenderPass.Scene;
ParticleVisibilityPolicy visibilityPolicy = ResolveVisibilityPolicy(ownerLocalId);
if (!_system.TrySpawnEmitterById( if (!_system.TrySpawnEmitterById(
emitterInfoId, emitterInfoId,
anchor, anchor,
@ -254,6 +292,7 @@ public sealed class ParticleHookSink : IAnimationHookSink
ownerLocalId, ownerLocalId,
partIndex, partIndex,
renderPass, renderPass,
visibilityPolicy,
out int handle)) out int handle))
{ {
DiagnosticSink?.Invoke( DiagnosticSink?.Invoke(
@ -261,6 +300,12 @@ public sealed class ParticleHookSink : IAnimationHookSink
$"0x{ownerLocalId:X8} was not found; no fallback was created."); $"0x{ownerLocalId:X8} was not found; no fallback was created.");
return; return;
} }
_system.UpdateEmitterOwnerPosition(handle, ownerPosition);
_system.UpdateEmitterOwnerCell(
handle,
_cells is not null && _cells.TryGetCellId(ownerLocalId, out uint cellId)
? cellId
: 0u);
if (_hiddenPresentationOwners.ContainsKey(ownerLocalId)) if (_hiddenPresentationOwners.ContainsKey(ownerLocalId))
{ {
_system.SetEmitterPresentationVisible(handle, false); _system.SetEmitterPresentationVisible(handle, false);
@ -282,20 +327,45 @@ public sealed class ParticleHookSink : IAnimationHookSink
_handlesByKey[(ownerLocalId, logicalId)] = handle; _handlesByKey[(ownerLocalId, logicalId)] = handle;
} }
private ParticleVisibilityPolicy ResolveVisibilityPolicy(uint ownerLocalId)
{
if (_examinationOwners.ContainsKey(ownerLocalId))
return ParticleVisibilityPolicy.Examination;
return _renderPassByEntity.TryGetValue(ownerLocalId, out ParticleRenderPass pass)
&& pass != ParticleRenderPass.Scene
? ParticleVisibilityPolicy.PassOwned
: ParticleVisibilityPolicy.World;
}
private void RefreshOwnerVisibilityPolicy(uint ownerLocalId)
{
if (!_handlesByEntity.TryGetValue(ownerLocalId, out var handles))
return;
ParticleVisibilityPolicy policy = ResolveVisibilityPolicy(ownerLocalId);
foreach (int handle in handles.Keys)
_system.SetEmitterVisibilityPolicy(handle, policy);
}
private bool TryResolveAnchor( private bool TryResolveAnchor(
uint ownerLocalId, uint ownerLocalId,
int partIndex, int partIndex,
Vector3 offsetOrigin, Vector3 offsetOrigin,
out Vector3 anchor, out Vector3 anchor,
out Quaternion rotation) out Quaternion rotation,
out Vector3 ownerPosition)
{ {
if (!_poses.TryGetRootPose(ownerLocalId, out Matrix4x4 rootWorld)) if (!_poses.TryGetRootPose(ownerLocalId, out Matrix4x4 rootWorld))
{ {
anchor = default; anchor = default;
rotation = default; rotation = default;
ownerPosition = default;
return false; return false;
} }
ownerPosition = new Vector3(rootWorld.M41, rootWorld.M42, rootWorld.M43);
Matrix4x4 ownerFrame = rootWorld; Matrix4x4 ownerFrame = rootWorld;
if (partIndex != -1) if (partIndex != -1)
{ {
@ -303,6 +373,7 @@ public sealed class ParticleHookSink : IAnimationHookSink
{ {
anchor = default; anchor = default;
rotation = default; rotation = default;
ownerPosition = default;
return false; return false;
} }
ownerFrame = partLocal * rootWorld; ownerFrame = partLocal * rootWorld;
@ -313,6 +384,7 @@ public sealed class ParticleHookSink : IAnimationHookSink
{ {
anchor = default; anchor = default;
rotation = default; rotation = default;
ownerPosition = default;
return false; return false;
} }
rotation = Quaternion.Normalize(rotation); rotation = Quaternion.Normalize(rotation);

View file

@ -35,7 +35,8 @@ public sealed class ParticleSystem : IParticleSystem
Quaternion? rot = null, Quaternion? rot = null,
uint attachedObjectId = 0, uint attachedObjectId = 0,
int attachedPartIndex = -1, int attachedPartIndex = -1,
ParticleRenderPass renderPass = ParticleRenderPass.Scene) ParticleRenderPass renderPass = ParticleRenderPass.Scene,
ParticleVisibilityPolicy visibilityPolicy = ParticleVisibilityPolicy.World)
{ {
ArgumentNullException.ThrowIfNull(desc); ArgumentNullException.ThrowIfNull(desc);
@ -45,10 +46,12 @@ public sealed class ParticleSystem : IParticleSystem
Handle = handle, Handle = handle,
Desc = desc, Desc = desc,
AnchorPos = anchor, AnchorPos = anchor,
OwnerPosition = anchor,
AnchorRot = rot ?? Quaternion.Identity, AnchorRot = rot ?? Quaternion.Identity,
AttachedObjectId = attachedObjectId, AttachedObjectId = attachedObjectId,
AttachedPartIndex = attachedPartIndex, AttachedPartIndex = attachedPartIndex,
RenderPass = renderPass, RenderPass = renderPass,
VisibilityPolicy = visibilityPolicy,
Particles = new Particle[Math.Max(1, desc.MaxParticles)], Particles = new Particle[Math.Max(1, desc.MaxParticles)],
StartedAt = _time, StartedAt = _time,
LastEmitTime = _time, LastEmitTime = _time,
@ -70,10 +73,18 @@ public sealed class ParticleSystem : IParticleSystem
Quaternion? rot = null, Quaternion? rot = null,
uint attachedObjectId = 0, uint attachedObjectId = 0,
int attachedPartIndex = -1, int attachedPartIndex = -1,
ParticleRenderPass renderPass = ParticleRenderPass.Scene) ParticleRenderPass renderPass = ParticleRenderPass.Scene,
ParticleVisibilityPolicy visibilityPolicy = ParticleVisibilityPolicy.World)
{ {
var desc = _registry.Get(emitterId); var desc = _registry.Get(emitterId);
return SpawnEmitter(desc, anchor, rot, attachedObjectId, attachedPartIndex, renderPass); return SpawnEmitter(
desc,
anchor,
rot,
attachedObjectId,
attachedPartIndex,
renderPass,
visibilityPolicy);
} }
public bool TrySpawnEmitterById( public bool TrySpawnEmitterById(
@ -83,6 +94,7 @@ public sealed class ParticleSystem : IParticleSystem
uint attachedObjectId, uint attachedObjectId,
int attachedPartIndex, int attachedPartIndex,
ParticleRenderPass renderPass, ParticleRenderPass renderPass,
ParticleVisibilityPolicy visibilityPolicy,
out int handle) out int handle)
{ {
if (!_registry.TryGet(emitterId, out EmitterDesc? desc)) if (!_registry.TryGet(emitterId, out EmitterDesc? desc))
@ -97,7 +109,8 @@ public sealed class ParticleSystem : IParticleSystem
rot, rot,
attachedObjectId, attachedObjectId,
attachedPartIndex, attachedPartIndex,
renderPass); renderPass,
visibilityPolicy);
return true; return true;
} }
@ -116,6 +129,7 @@ public sealed class ParticleSystem : IParticleSystem
{ {
for (int i = 0; i < em.Particles.Length; i++) for (int i = 0; i < em.Particles.Length; i++)
em.Particles[i].Alive = false; em.Particles[i].Alive = false;
em.ActiveCount = 0;
// Retail DestroyParticleEmitter removes the table entry now; it // Retail DestroyParticleEmitter removes the table entry now; it
// does not wait for the next update. This is also required for a // does not wait for the next update. This is also required for a
// hard-stopped emitter whose cell-less simulation is paused. // hard-stopped emitter whose cell-less simulation is paused.
@ -145,6 +159,77 @@ public sealed class ParticleSystem : IParticleSystem
em.AnchorRot = rot.Value; em.AnchorRot = rot.Value;
} }
/// <summary>
/// Refreshes the owning physics object's root used by retail's particle
/// degradation-distance check. This remains distinct from an animated
/// part's emitter anchor.
/// </summary>
public void UpdateEmitterOwnerPosition(int handle, Vector3 ownerPosition)
{
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
emitter.OwnerPosition = ownerPosition;
}
public void UpdateEmitterOwnerCell(int handle, uint ownerCellId)
{
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
emitter.OwnerCellId = ownerCellId;
}
public void SetEmitterVisibilityPolicy(
int handle,
ParticleVisibilityPolicy visibilityPolicy)
{
if (_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
emitter.VisibilityPolicy = visibilityPolicy;
}
/// <summary>
/// Applies <c>CPhysicsObj::ShouldDrawParticles</c> (0x0050FE60) to every
/// live emitter. The App layer supplies the previous completed retail
/// PView's cell set, equivalent to <c>CObjCell::IsInView</c> when the next
/// physics update runs.
/// </summary>
public void ApplyRetailView(
Vector3 viewerPosition,
IReadOnlySet<uint> visibleCellIds,
bool hasCompletedView,
float rangeMultiplier = 1f)
{
ArgumentNullException.ThrowIfNull(visibleCellIds);
if (!float.IsFinite(rangeMultiplier) || rangeMultiplier <= 0f)
rangeMultiplier = 1f;
foreach (int handle in _handleOrder)
{
if (!_byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
continue;
if (emitter.VisibilityPolicy != ParticleVisibilityPolicy.World)
{
emitter.ViewEligible = true;
continue;
}
if (!hasCompletedView)
{
// With no completed world PView (portal space, login, or a
// reset frame), a world cell cannot report IsInView.
emitter.ViewEligible = false;
continue;
}
float maxDistance = emitter.Desc.MaxDegradeDistance * rangeMultiplier;
float distance = RetailDistance(emitter.OwnerPosition, viewerPosition);
emitter.ViewEligible = emitter.OwnerCellId != 0
&& visibleCellIds.Contains(emitter.OwnerCellId)
// The x87 comparison in ShouldDrawParticles admits unordered
// comparisons (NaN) and reject a negative authored range.
&& (float.IsNaN(distance)
|| float.IsNaN(maxDistance)
|| distance <= maxDistance);
}
}
/// <summary> /// <summary>
/// Changes only render presentation. Logical lifetime is unaffected. /// Changes only render presentation. Logical lifetime is unaffected.
/// </summary> /// </summary>
@ -210,10 +295,19 @@ public sealed class ParticleSystem : IParticleSystem
continue; continue;
if (!em.SimulationEnabled) if (!em.SimulationEnabled)
continue; continue;
bool wasFinishedBeforeUpdate = em.Finished;
AdvanceEmitter(em); if (em.ViewEligible)
int live = CountAlive(em); {
em.ActiveCount = live; em.DegradedOut = false;
AdvanceEmitter(em);
}
else
{
em.DegradedOut = true;
AdvanceDegradedEmitter(em, dt);
}
int live = em.ActiveCount;
_activeParticleCount += live; _activeParticleCount += live;
if (em.Desc.TotalDuration > 0f && (_time - em.StartedAt) > em.Desc.TotalDuration) if (em.Desc.TotalDuration > 0f && (_time - em.StartedAt) > em.Desc.TotalDuration)
@ -222,7 +316,10 @@ public sealed class ParticleSystem : IParticleSystem
if (em.Desc.TotalParticles > 0 && em.TotalEmitted >= em.Desc.TotalParticles) if (em.Desc.TotalParticles > 0 && em.TotalEmitted >= em.Desc.TotalParticles)
em.Finished = true; em.Finished = true;
if (em.Finished && live == 0) // UpdateParticles returns true on the exact tick StopEmitter
// first changes state. Only the next update's already-stopped
// branch may return num_particles == 0 and retire the emitter.
if (em.Finished && live == 0 && wasFinishedBeforeUpdate)
{ {
_byHandle.Remove(handle); _byHandle.Remove(handle);
_handleOrder.RemoveAt(i); _handleOrder.RemoveAt(i);
@ -253,6 +350,66 @@ public sealed class ParticleSystem : IParticleSystem
/// </summary> /// </summary>
public LiveParticleEnumerable EnumerateLive() => new(this); public LiveParticleEnumerable EnumerateLive() => new(this);
/// <summary>
/// Enumerates live emitters in spawn order. The renderer consumes this
/// before particle slots so pass/visibility rejection is O(emitters), not
/// O(all particles in every pass).
/// </summary>
public LiveEmitterEnumerable EnumerateEmitters() => new(this);
public readonly struct LiveEmitterEnumerable : IEnumerable<ParticleEmitter>
{
private readonly ParticleSystem _owner;
internal LiveEmitterEnumerable(ParticleSystem owner) => _owner = owner;
public Enumerator GetEnumerator() => new(_owner);
IEnumerator<ParticleEmitter> IEnumerable<ParticleEmitter>.GetEnumerator()
=> EnumerateBoxed(_owner).GetEnumerator();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator()
=> EnumerateBoxed(_owner).GetEnumerator();
private static IEnumerable<ParticleEmitter> EnumerateBoxed(ParticleSystem owner)
{
foreach (int handle in owner._handleOrder)
{
if (owner._byHandle.TryGetValue(handle, out ParticleEmitter? emitter))
yield return emitter;
}
}
public struct Enumerator
{
private readonly ParticleSystem _owner;
private int _index;
internal Enumerator(ParticleSystem owner)
{
_owner = owner;
_index = -1;
Current = null!;
}
public ParticleEmitter Current { get; private set; }
public bool MoveNext()
{
while (++_index < _owner._handleOrder.Count)
{
if (_owner._byHandle.TryGetValue(
_owner._handleOrder[_index],
out ParticleEmitter? emitter))
{
Current = emitter;
return true;
}
}
return false;
}
}
}
/// <summary> /// <summary>
/// Struct enumerable returned by <see cref="EnumerateLive"/>. Wraps the /// Struct enumerable returned by <see cref="EnumerateLive"/>. Wraps the
/// owning <see cref="ParticleSystem"/> so <c>foreach</c> gets a /// owning <see cref="ParticleSystem"/> so <c>foreach</c> gets a
@ -342,10 +499,11 @@ public sealed class ParticleSystem : IParticleSystem
if (!p.Alive) if (!p.Alive)
continue; continue;
p.Age = _time - p.SpawnedAt; p.Age = EffectiveParticleAge(em, p);
if (p.Lifetime <= 0f || p.Age >= p.Lifetime) if (p.Lifetime <= 0f || p.Age >= p.Lifetime)
{ {
p.Alive = false; p.Alive = false;
em.ActiveCount--;
continue; continue;
} }
@ -380,13 +538,63 @@ public sealed class ParticleSystem : IParticleSystem
} }
} }
/// <summary>
/// Retail <c>ParticleEmitter::UpdateParticles</c> (0x0051D180)
/// degraded branch. Infinite emitters freeze particle age without normal
/// updates or emissions. Finite emitters age/retire existing particles,
/// record at most one due emission without creating a drawable particle,
/// then run retail's conditional stop test.
/// </summary>
private void AdvanceDegradedEmitter(ParticleEmitter em, float dt)
{
bool infinite = em.Desc.TotalParticles == 0 && em.Desc.TotalDuration == 0f;
if (infinite)
{
em.FrozenTime += dt;
// Callback-created descriptors sometimes use the legacy EmitRate
// representation. Rebase that accumulator so it cannot synthesize
// a catch-up burst after degradation; retail performs no backlog.
if (em.Desc.Birthrate <= 0f && em.Desc.EmitRate > 0f)
{
em.LastEmitTime = _time;
em.EmittedAccumulator = 0f;
}
return;
}
for (int i = 0; i < em.Particles.Length; i++)
{
ref Particle particle = ref em.Particles[i];
if (!particle.Alive)
continue;
particle.Age = _time - particle.SpawnedAt;
if (particle.Lifetime <= 0f || particle.Age >= particle.Lifetime)
{
particle.Alive = false;
em.ActiveCount--;
}
}
if (!em.Finished && ShouldEmitParticle(em))
{
// ParticleEmitter::RecordParticleEmission (0x0051C870) advances
// both num_particles and total_emitted while degraded, but no
// PhysicsPart is made visible.
em.ActiveCount++;
em.TotalEmitted++;
em.LastEmitTime = _time;
em.LastEmitOffset = em.AnchorPos;
}
}
private bool ShouldEmitParticle(ParticleEmitter em) private bool ShouldEmitParticle(ParticleEmitter em)
{ {
var desc = em.Desc; var desc = em.Desc;
if (desc.TotalParticles > 0 && em.TotalEmitted >= desc.TotalParticles) if (desc.TotalParticles > 0 && em.TotalEmitted >= desc.TotalParticles)
return false; return false;
if (CountAlive(em) >= desc.MaxParticles) if (em.ActiveCount >= desc.MaxParticles)
return false; return false;
if (desc.Birthrate <= 0f) if (desc.Birthrate <= 0f)
@ -410,9 +618,11 @@ public sealed class ParticleSystem : IParticleSystem
return false; return false;
ref var particle = ref em.Particles[slot]; ref var particle = ref em.Particles[slot];
bool replacesLiveParticle = particle.Alive;
particle = default; particle = default;
particle.Alive = true; particle.Alive = true;
particle.SpawnedAt = _time; particle.SpawnedAt = _time;
particle.FrozenTimeAtSpawn = em.FrozenTime;
particle.Lifetime = RandomLifespan(em.Desc); particle.Lifetime = RandomLifespan(em.Desc);
particle.EmissionOrigin = em.AnchorPos; particle.EmissionOrigin = em.AnchorPos;
particle.SpawnRotation = em.AnchorRot; particle.SpawnRotation = em.AnchorRot;
@ -448,11 +658,17 @@ public sealed class ParticleSystem : IParticleSystem
particle.Position = ComputePosition(em, particle); particle.Position = ComputePosition(em, particle);
em.TotalEmitted++; em.TotalEmitted++;
if (!replacesLiveParticle)
em.ActiveCount++;
em.LastEmitTime = _time; em.LastEmitTime = _time;
em.LastEmitOffset = em.AnchorPos; em.LastEmitOffset = em.AnchorPos;
return true; return true;
} }
private float EffectiveParticleAge(ParticleEmitter emitter, in Particle particle)
=> _time - particle.SpawnedAt
- (emitter.FrozenTime - particle.FrozenTimeAtSpawn);
private Vector3 ComputePosition(ParticleEmitter em, Particle p) private Vector3 ComputePosition(ParticleEmitter em, Particle p)
{ {
float t = p.Age; float t = p.Age;
@ -599,16 +815,22 @@ public sealed class ParticleSystem : IParticleSystem
return slot; return slot;
} }
private static int CountAlive(ParticleEmitter em) private static float RetailDistance(Vector3 a, Vector3 b)
{ {
int n = 0; // Overflow-safe Euclidean length. Vector3.DistanceSquared overflows
for (int i = 0; i < em.Particles.Length; i++) // for finite coordinates near FLT_MAX, whereas retail compares its
{ // already-computed direct distance against max_dist.
if (em.Particles[i].Alive) float dx = a.X - b.X;
n++; float dy = a.Y - b.Y;
} float dz = a.Z - b.Z;
float scale = MathF.Max(MathF.Abs(dx), MathF.Max(MathF.Abs(dy), MathF.Abs(dz)));
if (float.IsNaN(scale) || float.IsInfinity(scale) || scale == 0f)
return scale;
return n; dx /= scale;
dy /= scale;
dz /= scale;
return scale * MathF.Sqrt(dx * dx + dy * dy + dz * dz);
} }
private float RandomLifespan(EmitterDesc desc) private float RandomLifespan(EmitterDesc desc)

View file

@ -46,6 +46,18 @@ public enum ParticleRenderPass
SkyPostScene = 2, SkyPostScene = 2,
} }
/// <summary>
/// Authority used by retail's particle presentation gate. World-owned
/// emitters follow <c>CPhysicsObj::ShouldDrawParticles</c>; examination and
/// dedicated render-pass emitters are explicitly exempt from world PView.
/// </summary>
public enum ParticleVisibilityPolicy
{
World = 0,
Examination = 1,
PassOwned = 2,
}
[Flags] [Flags]
public enum EmitterFlags : uint public enum EmitterFlags : uint
{ {
@ -62,6 +74,12 @@ public enum EmitterFlags : uint
/// </summary> /// </summary>
public sealed class EmitterDesc public sealed class EmitterDesc
{ {
/// <summary>
/// Retail distance at which the owning physics object degrades this
/// emitter out. Sourced from the hardware particle GfxObj's degradation
/// table by <c>CPhysicsPart::GetMaxDegradeDistance</c> (0x0050D510).
/// </summary>
public float MaxDegradeDistance { get; init; } = 100f;
public uint DatId { get; init; } public uint DatId { get; init; }
public ParticleType Type { get; init; } public ParticleType Type { get; init; }
public ParticleEmitterKind EmitterKind { get; init; } = ParticleEmitterKind.BirthratePerSec; public ParticleEmitterKind EmitterKind { get; init; } = ParticleEmitterKind.BirthratePerSec;
@ -158,6 +176,12 @@ public struct Particle
public Vector3 B; public Vector3 B;
public Vector3 C; public Vector3 C;
public float SpawnedAt; public float SpawnedAt;
/// <summary>
/// Emitter-level frozen time at birth. This lets an infinite degraded
/// emitter freeze every particle's age in O(1), equivalent to retail
/// rewriting every live particle birth time to the current game time.
/// </summary>
public float FrozenTimeAtSpawn;
public float Lifetime; public float Lifetime;
public float Age; public float Age;
public float StartSize; public float StartSize;
@ -184,9 +208,22 @@ public sealed class ParticleEmitter
public int Handle { get; init; } public int Handle { get; init; }
public EmitterDesc Desc { get; init; } = null!; public EmitterDesc Desc { get; init; } = null!;
public Vector3 AnchorPos { get; set; } public Vector3 AnchorPos { get; set; }
/// <summary>
/// Root position of the owning physics object. Retail measures particle
/// degradation from the owner (<c>CPhysicsObj::CYpt</c>), not from an
/// animated hand/part hook offset.
/// </summary>
public Vector3 OwnerPosition { get; set; }
/// <summary>Current owning AC cell for retail <c>IsInView</c> gating.</summary>
public uint OwnerCellId { get; set; }
public Quaternion AnchorRot { get; set; } = Quaternion.Identity; public Quaternion AnchorRot { get; set; } = Quaternion.Identity;
public uint AttachedObjectId { get; set; } public uint AttachedObjectId { get; set; }
/// <summary> /// <summary>
/// Explicit presentation context. Retail's examination-object bypass is
/// object state, not an inference from attachment identity.
/// </summary>
public ParticleVisibilityPolicy VisibilityPolicy { get; set; }
/// <summary>
/// Spatial presentation latch. A cell-less owner submits no particles. /// Spatial presentation latch. A cell-less owner submits no particles.
/// </summary> /// </summary>
public bool PresentationVisible { get; set; } = true; public bool PresentationVisible { get; set; } = true;
@ -195,6 +232,13 @@ public sealed class ParticleEmitter
/// particles, logical identity, and elapsed-time position until re-entry. /// particles, logical identity, and elapsed-time position until re-entry.
/// </summary> /// </summary>
public bool SimulationEnabled { get; set; } = true; public bool SimulationEnabled { get; set; } = true;
/// <summary>
/// Result of retail <c>CPhysicsObj::ShouldDrawParticles</c>: authored
/// distance plus the previous completed PView's visibility state.
/// </summary>
public bool ViewEligible { get; set; } = true;
/// <summary>Retail <c>ParticleEmitter::degraded_out</c> latch.</summary>
public bool DegradedOut { get; set; }
public int AttachedPartIndex { get; set; } = -1; public int AttachedPartIndex { get; set; } = -1;
public Particle[] Particles { get; init; } = null!; public Particle[] Particles { get; init; } = null!;
public ParticleRenderPass RenderPass { get; init; } public ParticleRenderPass RenderPass { get; init; }
@ -202,6 +246,11 @@ public sealed class ParticleEmitter
public float EmittedAccumulator; public float EmittedAccumulator;
public float StartedAt; public float StartedAt;
public float LastEmitTime; public float LastEmitTime;
/// <summary>
/// Total time this infinite emitter spent degraded. Particles subtract
/// the delta since their own birth rather than being rewritten one by one.
/// </summary>
public float FrozenTime;
public Vector3 LastEmitOffset; public Vector3 LastEmitOffset;
public int TotalEmitted; public int TotalEmitted;
public bool Finished; public bool Finished;
@ -219,7 +268,8 @@ public interface IParticleSystem
Quaternion? rot = null, Quaternion? rot = null,
uint attachedObjectId = 0, uint attachedObjectId = 0,
int attachedPartIndex = -1, int attachedPartIndex = -1,
ParticleRenderPass renderPass = ParticleRenderPass.Scene); ParticleRenderPass renderPass = ParticleRenderPass.Scene,
ParticleVisibilityPolicy visibilityPolicy = ParticleVisibilityPolicy.World);
/// <summary>Fire a full PhysicsScript at a target.</summary> /// <summary>Fire a full PhysicsScript at a target.</summary>
void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f); void PlayScript(uint scriptId, uint targetObjectId, float modifier = 1f);

View file

@ -1,6 +1,7 @@
using DatReaderWriter; using DatReaderWriter;
using DatReaderWriter.DBObjs; using DatReaderWriter.DBObjs;
using DatReaderWriter.Types; using DatReaderWriter.Types;
using AcDream.Core.Physics;
namespace AcDream.Core.World; namespace AcDream.Core.World;
@ -74,6 +75,7 @@ public static class LandblockLoader
Position = stab.Frame.Origin, Position = stab.Frame.Origin,
Rotation = stab.Frame.Orientation, Rotation = stab.Frame.Orientation,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
EffectCellId = OutdoorCellId(landblockId, stab.Frame.Origin),
}; };
stabEntity.RefreshAabb(); // A.5 T18: populate cached AABB at construction stabEntity.RefreshAabb(); // A.5 T18: populate cached AABB at construction
result.Add(stabEntity); result.Add(stabEntity);
@ -90,6 +92,7 @@ public static class LandblockLoader
Position = building.Frame.Origin, Position = building.Frame.Origin,
Rotation = building.Frame.Orientation, Rotation = building.Frame.Orientation,
MeshRefs = Array.Empty<MeshRef>(), MeshRefs = Array.Empty<MeshRef>(),
EffectCellId = OutdoorCellId(landblockId, building.Frame.Origin),
IsBuildingShell = true, // Phase A8: tag at source array boundary IsBuildingShell = true, // Phase A8: tag at source array boundary
BuildingShellAnchorCellId = FirstBuildingAnchorCellId(building, landblockId), BuildingShellAnchorCellId = FirstBuildingAnchorCellId(building, landblockId),
}; };
@ -106,6 +109,17 @@ public static class LandblockLoader
return type == GfxObjMask || type == SetupMask; return type == GfxObjMask || type == SetupMask;
} }
private static uint? OutdoorCellId(uint landblockId, System.Numerics.Vector3 localPosition)
{
if (landblockId == 0)
return null;
return TerrainSurface.ComputeOutdoorCellId(
landblockId,
localPosition.X,
localPosition.Y);
}
private static uint? FirstBuildingAnchorCellId(BuildingInfo building, uint landblockId) private static uint? FirstBuildingAnchorCellId(BuildingInfo building, uint landblockId)
{ {
if (landblockId == 0) if (landblockId == 0)

View file

@ -92,6 +92,15 @@ public sealed class WorldEntity
/// </summary> /// </summary>
public uint? ParentCellId { get; set; } public uint? ParentCellId { get; set; }
/// <summary>
/// Owning AC cell used by entity-attached effects. This is separate from
/// <see cref="ParentCellId"/> because outdoor dat stabs deliberately keep
/// a null render parent while retail still gives their physics object an
/// outdoor landcell for <c>CObjCell::IsInView</c> particle gating.
/// Live/interior entities normally use <see cref="ParentCellId"/> instead.
/// </summary>
public uint? EffectCellId { get; set; }
/// <summary> /// <summary>
/// True when this entity originates from <c>LandBlockInfo.Buildings[]</c> /// True when this entity originates from <c>LandBlockInfo.Buildings[]</c>
/// (the dat array that carries building shells: cottage walls, smithy walls, /// (the dat array that carries building shells: cottage walls, smithy walls,

View file

@ -3,6 +3,17 @@ using AcDream.UI.Abstractions.Settings;
namespace AcDream.UI.Abstractions.Panels.Settings; namespace AcDream.UI.Abstractions.Panels.Settings;
/// <summary>
/// Particle visibility distance policy. Retail uses the GfxObj-authored
/// distance exactly; Extended is an explicit acdream adaptation for players
/// who prefer longer-lived distant effects with the corresponding CPU cost.
/// </summary>
public enum ParticleRange
{
Retail = 0,
Extended = 1,
}
/// <summary> /// <summary>
/// Display-related preferences persisted to <c>settings.json</c>. /// Display-related preferences persisted to <c>settings.json</c>.
/// Modern addition (no retail equivalent for FOV / vsync etc) — replaces /// Modern addition (no retail equivalent for FOV / vsync etc) — replaces
@ -22,7 +33,8 @@ public sealed record DisplaySettings(
float FieldOfView, float FieldOfView,
float Gamma, float Gamma,
bool ShowFps, bool ShowFps,
QualityPreset Quality) QualityPreset Quality,
ParticleRange ParticleRange)
{ {
/// <summary>Values used on first launch / when settings.json is absent. /// <summary>Values used on first launch / when settings.json is absent.
/// Geometry/render defaults preserve the pre-L.0 runtime state — Resolution /// Geometry/render defaults preserve the pre-L.0 runtime state — Resolution
@ -36,7 +48,8 @@ public sealed record DisplaySettings(
FieldOfView: 60f, FieldOfView: 60f,
Gamma: 1.0f, Gamma: 1.0f,
ShowFps: false, ShowFps: false,
Quality: QualityPreset.High); Quality: QualityPreset.High,
ParticleRange: ParticleRange.Retail);
/// <summary>16:9 resolution presets offered in the dropdown.</summary> /// <summary>16:9 resolution presets offered in the dropdown.</summary>
public static IReadOnlyList<string> AvailableResolutions { get; } = new[] public static IReadOnlyList<string> AvailableResolutions { get; } = new[]

View file

@ -236,12 +236,20 @@ public sealed class SettingsPanel : IPanel
if (renderer.Combo("Quality", ref qIdx, presets)) if (renderer.Combo("Quality", ref qIdx, presets))
_vm.SetDisplay(d with { Quality = (QualityPreset)qIdx }); _vm.SetDisplay(d with { Quality = (QualityPreset)qIdx });
int particleRangeIndex = (int)d.ParticleRange;
if (particleRangeIndex < 0 || particleRangeIndex >= s_particleRangeNames.Length)
particleRangeIndex = (int)ParticleRange.Retail;
if (renderer.Combo("Particle Range", ref particleRangeIndex, s_particleRangeNames))
_vm.SetDisplay(d with { ParticleRange = (ParticleRange)particleRangeIndex });
renderer.Spacing(); renderer.Spacing();
renderer.TextWrapped( renderer.TextWrapped(
"Resolution / Fullscreen / V-Sync apply on Save. FOV + Gamma " "Resolution / Fullscreen / V-Sync apply on Save. FOV + Gamma "
+ "preview live as you drag; Cancel reverts to the saved value. " + "preview live as you drag; Cancel reverts to the saved value. "
+ "Quality preset applies streaming radius, anisotropic, and A2C " + "Quality preset applies streaming radius, anisotropic, and A2C "
+ "immediately on Save; MSAA sample count requires a restart."); + "immediately on Save; MSAA sample count requires a restart. "
+ "Particle Range defaults to retail-authored distances; Extended "
+ "doubles effect range and costs additional CPU time.");
} }
/// <summary> /// <summary>
@ -474,6 +482,9 @@ public sealed class SettingsPanel : IPanel
private static readonly string[] s_qualityPresetNames = private static readonly string[] s_qualityPresetNames =
{ "Low", "Medium", "High", "Ultra" }; { "Low", "Medium", "High", "Ultra" };
private static readonly string[] s_particleRangeNames =
{ "Retail", "Extended" };
private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions) private void RenderSection(IPanelRenderer renderer, string label, InputAction[] actions)
{ {
// Movement defaults open; other sections collapsed for first-run UX. // Movement defaults open; other sections collapsed for first-run UX.

View file

@ -73,7 +73,9 @@ public sealed class SettingsStore
FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView), FieldOfView: ReadFloat (disp, "fieldOfView", d.FieldOfView),
Gamma: ReadFloat (disp, "gamma", d.Gamma), Gamma: ReadFloat (disp, "gamma", d.Gamma),
ShowFps: ReadBool (disp, "showFps", d.ShowFps), ShowFps: ReadBool (disp, "showFps", d.ShowFps),
Quality: ReadQuality (disp, "quality", d.Quality)); Quality: ReadQuality (disp, "quality", d.Quality),
ParticleRange: ReadParticleRange(
disp, "particleRange", d.ParticleRange));
} }
catch (Exception ex) catch (Exception ex)
{ {
@ -569,6 +571,7 @@ public sealed class SettingsStore
["fieldOfView"] = d.FieldOfView, ["fieldOfView"] = d.FieldOfView,
["fullscreen"] = d.Fullscreen, ["fullscreen"] = d.Fullscreen,
["gamma"] = d.Gamma, ["gamma"] = d.Gamma,
["particleRange"] = d.ParticleRange.ToString(),
["quality"] = d.Quality.ToString(), ["quality"] = d.Quality.ToString(),
["resolution"] = d.Resolution, ["resolution"] = d.Resolution,
["showFps"] = d.ShowFps, ["showFps"] = d.ShowFps,
@ -656,4 +659,18 @@ public sealed class SettingsStore
var s = el.GetString(); var s = el.GetString();
return Enum.TryParse<QualityPreset>(s, ignoreCase: true, out var v) ? v : fallback; return Enum.TryParse<QualityPreset>(s, ignoreCase: true, out var v) ? v : fallback;
} }
private static ParticleRange ReadParticleRange(
JsonElement obj,
string name,
ParticleRange fallback)
{
if (!obj.TryGetProperty(name, out var el) || el.ValueKind != JsonValueKind.String)
return fallback;
var value = el.GetString();
return Enum.TryParse(value, ignoreCase: true, out ParticleRange parsed)
&& Enum.IsDefined(parsed)
? parsed
: fallback;
}
} }

View file

@ -167,6 +167,26 @@ public sealed class RetailParticleGeometryClassifierTests
Assert.Equal(0x33000331u, resolver.Resolve(0x34000004u, 0x76u, 1f)); Assert.Equal(0x33000331u, resolver.Resolve(0x34000004u, 0x76u, 1f));
} }
[Fact]
public void InstalledAerlintheSwarmEmitter_UsesItsHardwareGfxDegradeDistance()
{
string? datDir = ResolveDatDir();
if (datDir is null)
throw SkipException.ForSkip("Installed client_portal.dat is required.");
using var dats = new DatCollection(datDir, DatAccessType.Read);
var emitters = new EmitterDescRegistry(dats);
EmitterDesc desc = emitters.Get(0x32000223u);
GfxObj gfx = Assert.IsType<GfxObj>(dats.Get<GfxObj>(desc.HwGfxObjId));
GfxObjDegradeInfo degrade = Assert.IsType<GfxObjDegradeInfo>(
dats.Get<GfxObjDegradeInfo>(gfx.DIDDegrade));
Assert.Equal(
RetailParticleDegradeDistance.FromEntries(degrade.Degrades),
desc.MaxDegradeDistance);
Assert.Equal(64f, desc.MaxDegradeDistance);
}
private static string? ResolveDatDir() private static string? ResolveDatDir()
{ {
string? configured = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR"); string? configured = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");

View file

@ -0,0 +1,119 @@
using System.Numerics;
using AcDream.App.Rendering;
namespace AcDream.App.Tests.Rendering;
public sealed class TerrainParticleCellVisibilityTests
{
[Fact]
public void CollectVisibleCells_PublishesLandscapeCellsWithoutEntitySurvivors()
{
var cells = new HashSet<uint>();
TerrainModernRenderer.CollectVisibleCells(
cells,
0xA9B4FFFFu,
Vector3.Zero,
zMin: 0f,
zMax: 20f,
frustum: null,
Matrix4x4.Identity,
ReadOnlySpan<Vector4>.Empty);
Assert.Equal(64, cells.Count);
Assert.Contains(0xA9B40001u, cells);
Assert.Contains(0xA9B40040u, cells);
}
[Fact]
public void CollectVisibleCells_ForceDrawnParentStillUsesCellFrustum()
{
var cells = new HashSet<uint>();
FrustumPlanes frustum = FrustumPlanes.FromViewProjection(Matrix4x4.Identity);
// The parent landblock may have entered TerrainModernRenderer's draw
// list through neverCullLandblockId. The cell collector deliberately
// receives no such bypass and still rejects its off-frustum cells.
TerrainModernRenderer.CollectVisibleCells(
cells,
0xA9B4FFFFu,
new Vector3(100f, 100f, 100f),
zMin: 100f,
zMax: 120f,
frustum,
Matrix4x4.Identity,
ReadOnlySpan<Vector4>.Empty);
Assert.Empty(cells);
}
[Fact]
public void CollectVisibleCells_RejectsCellsOutsideDoorwayClipPlanes()
{
var cells = new HashSet<uint>();
var planes = new[] { new Vector4(-1f, 0f, 0f, -2f) };
TerrainModernRenderer.CollectVisibleCells(
cells,
0xA9B4FFFFu,
Vector3.Zero,
zMin: 0f,
zMax: 20f,
frustum: null,
Matrix4x4.Identity,
planes);
Assert.Empty(cells);
}
[Fact]
public void CollectVisibleCells_RejectsCellsOutsideDoorwayScissorAabb()
{
var cells = new HashSet<uint>();
TerrainModernRenderer.CollectVisibleCells(
cells,
0xA9B4FFFFu,
Vector3.Zero,
zMin: 0f,
zMax: 20f,
frustum: null,
Matrix4x4.Identity,
ReadOnlySpan<Vector4>.Empty,
ndcClipAabb: new Vector4(-4f, -4f, -2f, -2f));
Assert.Empty(cells);
}
[Fact]
public void CollectVisibleCells_UnionsCellsFromEveryLandscapeSlice()
{
var cells = new HashSet<uint>();
TerrainModernRenderer.CollectVisibleCells(
cells,
0xA9B4FFFFu,
Vector3.Zero,
zMin: 0f,
zMax: 20f,
frustum: null,
Matrix4x4.Identity,
ReadOnlySpan<Vector4>.Empty,
ndcClipAabb: new Vector4(1f, -1_000f, 23f, 1_000f));
TerrainModernRenderer.CollectVisibleCells(
cells,
0xA9B4FFFFu,
Vector3.Zero,
zMin: 0f,
zMax: 20f,
frustum: null,
Matrix4x4.Identity,
ReadOnlySpan<Vector4>.Empty,
ndcClipAabb: new Vector4(49f, -1_000f, 71f, 1_000f));
Assert.Equal(16, cells.Count);
Assert.Contains(0xA9B40001u, cells);
Assert.Contains(0xA9B40011u, cells);
Assert.DoesNotContain(0xA9B40009u, cells);
}
}

View file

@ -140,6 +140,20 @@ public sealed class EntityEffectPoseRegistryTests
Assert.Equal(0, queue.Count); Assert.Equal(0, queue.Count);
} }
[Fact]
public void Publish_UsesOutdoorEffectCellWithoutChangingRenderParent()
{
var poses = new EntityEffectPoseRegistry();
WorldEntity entity = Entity(12u, Vector3.Zero);
entity.ParentCellId = null;
entity.EffectCellId = 0xA9B4000Bu;
poses.Publish(entity, Array.Empty<Matrix4x4>());
Assert.True(poses.TryGetCellId(entity.Id, out uint cellId));
Assert.Equal(0xA9B4000Bu, cellId);
}
private static WorldEntity Entity(uint id, Vector3 position) => new() private static WorldEntity Entity(uint id, Vector3 position) => new()
{ {
Id = id, Id = id,

View file

@ -0,0 +1,67 @@
using System.Numerics;
using AcDream.App.Rendering.Vfx;
using AcDream.Core.Vfx;
namespace AcDream.App.Tests.Rendering.Vfx;
public sealed class ParticleVisibilityControllerTests
{
[Fact]
public void CompletedRetailViewFeedsNextParticleUpdate()
{
var particles = new ParticleSystem(new EmitterDescRegistry(), new Random(1));
int handle = particles.SpawnEmitter(
new EmitterDesc
{
DatId = 0x32000001u,
Type = ParticleType.Still,
MaxDegradeDistance = 100f,
MaxParticles = 1,
InitialParticles = 1,
LifetimeMin = 10f,
LifetimeMax = 10f,
},
Vector3.Zero,
attachedObjectId: 99u);
particles.UpdateEmitterOwnerCell(handle, 0x01010001u);
var controller = new ParticleVisibilityController();
controller.BeginFrame(Vector3.Zero);
controller.UseWorldView();
controller.MarkVisibleCells(new HashSet<uint> { 0x01010001u });
controller.CompleteFrame();
controller.Apply(particles, 1f);
Assert.True(Assert.Single(particles.EnumerateEmitters()).ViewEligible);
Assert.Equal(handle, Assert.Single(particles.EnumerateEmitters()).Handle);
}
[Fact]
public void UnresolvedFramePublishesEmptyWorldView()
{
var particles = new ParticleSystem(new EmitterDescRegistry(), new Random(1));
int handle = particles.SpawnEmitter(
new EmitterDesc
{
DatId = 0x32000002u,
Type = ParticleType.Still,
MaxDegradeDistance = 100f,
MaxParticles = 1,
},
Vector3.Zero,
attachedObjectId: 42u);
particles.UpdateEmitterOwnerCell(handle, 0x01010001u);
var controller = new ParticleVisibilityController();
controller.BeginFrame(Vector3.Zero);
controller.UseWorldView();
controller.CompleteFrame();
controller.Apply(particles, 1f);
Assert.False(Assert.Single(particles.EnumerateEmitters()).ViewEligible);
controller.BeginFrame(Vector3.Zero);
controller.CompleteFrame();
controller.Apply(particles, 1f);
Assert.False(Assert.Single(particles.EnumerateEmitters()).ViewEligible);
}
}

View file

@ -316,6 +316,8 @@ public sealed class ParticleHookSinkTests
Assert.True(emitter.PresentationVisible); Assert.True(emitter.PresentationVisible);
Assert.Equal(spawnedAt, emitter.Particles[0].SpawnedAt); Assert.Equal(spawnedAt, emitter.Particles[0].SpawnedAt);
system.Tick(0.01f);
Assert.Equal(1, system.ActiveEmitterCount);
system.Tick(0.01f); system.Tick(0.01f);
Assert.Equal(0, system.ActiveEmitterCount); Assert.Equal(0, system.ActiveEmitterCount);
} }
@ -336,6 +338,30 @@ public sealed class ParticleHookSinkTests
Assert.Equal(1, deathNotices); Assert.Equal(1, deathNotices);
} }
[Fact]
public void ExaminationObjectPolicyAppliesToExistingAndFutureEmittersAndTearsDown()
{
const uint emitterId = 0x32000058u;
var (system, sink, _) = Harness(emitterId);
sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId: 21u));
sink.SetEntityExaminationObject(Owner, true);
Assert.Equal(
ParticleVisibilityPolicy.Examination,
Assert.Single(system.EnumerateEmitters()).VisibilityPolicy);
sink.OnHook(Owner, Vector3.Zero, Create(emitterId, logicalId: 22u));
Assert.All(
system.EnumerateEmitters(),
emitter => Assert.Equal(
ParticleVisibilityPolicy.Examination,
emitter.VisibilityPolicy));
Assert.Equal(1, sink.ExaminationOwnerCount);
sink.StopAllForEntity(Owner, fadeOut: false);
Assert.Equal(0, sink.ExaminationOwnerCount);
}
private sealed class MutablePoseSource : IEntityEffectPoseSource private sealed class MutablePoseSource : IEntityEffectPoseSource
{ {
private readonly Dictionary<uint, (Matrix4x4 Root, Matrix4x4[] Parts)> _poses = new(); private readonly Dictionary<uint, (Matrix4x4 Root, Matrix4x4[] Parts)> _poses = new();

View file

@ -1,6 +1,7 @@
using System.Linq; using System.Linq;
using System.Numerics; using System.Numerics;
using AcDream.Core.Vfx; using AcDream.Core.Vfx;
using DatReaderWriter.Types;
using Xunit; using Xunit;
namespace AcDream.Core.Tests.Vfx; namespace AcDream.Core.Tests.Vfx;
@ -22,7 +23,8 @@ public sealed class ParticleSystemTests
LifetimeMin = lifetime, LifetimeMin = lifetime,
LifetimeMax = lifetime, LifetimeMax = lifetime,
OffsetDir = Vector3.UnitZ, OffsetDir = Vector3.UnitZ,
MinOffset = 0, MaxOffset = 0, MinOffset = 0,
MaxOffset = 0,
SpawnDiskRadius = 0, SpawnDiskRadius = 0,
InitialVelocity = new Vector3(0, 0, 1f), InitialVelocity = new Vector3(0, 0, 1f),
VelocityJitter = 0, VelocityJitter = 0,
@ -142,11 +144,14 @@ public sealed class ParticleSystemTests
Type = ParticleType.ParabolicLVGA, Type = ParticleType.ParabolicLVGA,
EmitRate = 10f, EmitRate = 10f,
MaxParticles = 100, MaxParticles = 100,
LifetimeMin = 2f, LifetimeMax = 2f, LifetimeMin = 2f,
LifetimeMax = 2f,
OffsetDir = Vector3.UnitZ, OffsetDir = Vector3.UnitZ,
InitialVelocity = new Vector3(0, 0, 5f), // straight up InitialVelocity = new Vector3(0, 0, 5f), // straight up
StartSize = 0.5f, EndSize = 0.5f, StartSize = 0.5f,
StartAlpha = 1f, EndAlpha = 0f, EndSize = 0.5f,
StartAlpha = 1f,
EndAlpha = 0f,
Gravity = new Vector3(0, 0, -10f), // strong gravity Gravity = new Vector3(0, 0, -10f), // strong gravity
}; };
sys.SpawnEmitter(desc, Vector3.Zero); sys.SpawnEmitter(desc, Vector3.Zero);
@ -179,13 +184,17 @@ public sealed class ParticleSystemTests
Type = ParticleType.Explode, Type = ParticleType.Explode,
EmitRate = 20f, EmitRate = 20f,
MaxParticles = 100, MaxParticles = 100,
LifetimeMin = 2f, LifetimeMax = 2f, LifetimeMin = 2f,
LifetimeMax = 2f,
OffsetDir = Vector3.UnitZ, OffsetDir = Vector3.UnitZ,
MinOffset = 0.5f, MaxOffset = 0.5f, MinOffset = 0.5f,
MaxOffset = 0.5f,
SpawnDiskRadius = 0.5f, SpawnDiskRadius = 0.5f,
InitialVelocity = new Vector3(1, 0, 0), // magnitude = 1 InitialVelocity = new Vector3(1, 0, 0), // magnitude = 1
StartSize = 0.5f, EndSize = 0.5f, StartSize = 0.5f,
StartAlpha = 1f, EndAlpha = 0f, EndSize = 0.5f,
StartAlpha = 1f,
EndAlpha = 0f,
}; };
sys.SpawnEmitter(desc, Vector3.Zero); sys.SpawnEmitter(desc, Vector3.Zero);
@ -377,6 +386,383 @@ public sealed class ParticleSystemTests
Assert.True((desc.Flags & EmitterFlags.AttachLocal) != 0); Assert.True((desc.Flags & EmitterFlags.AttachLocal) != 0);
} }
[Fact]
public void EmitterDescRegistry_DoesNotSubstituteSoftwareGfxForMissingHardwareGfx()
{
var dat = new DatReaderWriter.DBObjs.ParticleEmitter
{
GfxObjId = 0x01000001u,
HwGfxObjId = 0u,
};
Assert.Equal(0u, EmitterDescRegistry.GetRetailHardwareGfxObjId(dat));
}
[Fact]
public void RetailParticleDegradeDistance_MatchesRetailEntrySelection()
{
var one = new List<GfxObjInfo>
{
new() { MaxDist = 11f },
};
var two = new List<GfxObjInfo>
{
new() { MaxDist = 21f },
new() { MaxDist = 22f },
};
var four = new List<GfxObjInfo>
{
new() { MaxDist = 31f },
new() { MaxDist = 32f },
new() { MaxDist = 33f },
new() { MaxDist = 34f },
};
Assert.Equal(100f, RetailParticleDegradeDistance.FromEntries(null));
Assert.Equal(11f, RetailParticleDegradeDistance.FromEntries(one));
Assert.Equal(21f, RetailParticleDegradeDistance.FromEntries(two));
Assert.Equal(33f, RetailParticleDegradeDistance.FromEntries(four));
}
[Fact]
public void RetailParticleDegradeDistance_PreservesRawAuthoredValue()
{
Assert.Equal(-1f, RetailParticleDegradeDistance.FromEntries(
new List<GfxObjInfo> { new() { MaxDist = -1f } }));
Assert.Equal(float.PositiveInfinity, RetailParticleDegradeDistance.FromEntries(
new List<GfxObjInfo> { new() { MaxDist = float.PositiveInfinity } }));
Assert.True(float.IsNaN(RetailParticleDegradeDistance.FromEntries(
new List<GfxObjInfo> { new() { MaxDist = float.NaN } })));
}
[Fact]
public void ApplyRetailView_UsesOwnerVisibilityAndInclusiveAuthoredDistance()
{
var sys = MakeSystem();
var desc = new EmitterDesc
{
DatId = 0x32000076u,
Type = ParticleType.Still,
MaxDegradeDistance = 10f,
MaxParticles = 1,
InitialParticles = 1,
LifetimeMin = 10f,
LifetimeMax = 10f,
StartSize = 1f,
EndSize = 1f,
StartAlpha = 1f,
EndAlpha = 1f,
};
int handle = sys.SpawnEmitter(
desc,
new Vector3(10f, 0f, 0f),
attachedObjectId: 7u);
sys.UpdateEmitterOwnerCell(handle, 0x01010001u);
sys.ApplyRetailView(Vector3.Zero, new HashSet<uint> { 0x01010001u }, true);
Assert.True(Assert.Single(sys.EnumerateEmitters()).ViewEligible);
sys.ApplyRetailView(Vector3.Zero, new HashSet<uint>(), true);
Assert.False(Assert.Single(sys.EnumerateEmitters()).ViewEligible);
sys.ApplyRetailView(
Vector3.Zero,
new HashSet<uint> { 0x01010001u },
true,
rangeMultiplier: 0.5f);
Assert.False(Assert.Single(sys.EnumerateEmitters()).ViewEligible);
}
[Fact]
public void ApplyRetailView_UnorderedOwnerDistanceMatchesRetailX87Comparison()
{
var sys = MakeSystem();
int handle = sys.SpawnEmitter(
new EmitterDesc
{
DatId = 0x32000079u,
Type = ParticleType.Still,
MaxDegradeDistance = 100f,
MaxParticles = 1,
},
Vector3.Zero,
attachedObjectId: 12u);
sys.UpdateEmitterOwnerPosition(handle, new Vector3(float.NaN, 0f, 0f));
sys.UpdateEmitterOwnerCell(handle, 0x0101000Cu);
sys.ApplyRetailView(
Vector3.Zero,
new HashSet<uint> { 0x0101000Cu },
true);
Assert.True(Assert.Single(sys.EnumerateEmitters()).ViewEligible);
}
[Fact]
public void ApplyRetailView_UsesOverflowSafeDirectDistanceAndRawRange()
{
var visible = new HashSet<uint> { 0x0101000Du };
static ParticleSystem Spawn(float maxDistance, Vector3 owner, out int handle)
{
var system = MakeSystem();
handle = system.SpawnEmitter(
new EmitterDesc
{
DatId = 0x3200007Au,
Type = ParticleType.Still,
MaxDegradeDistance = maxDistance,
MaxParticles = 1,
},
owner,
attachedObjectId: 13u);
system.UpdateEmitterOwnerPosition(handle, owner);
system.UpdateEmitterOwnerCell(handle, 0x0101000Du);
return system;
}
ParticleSystem huge = Spawn(float.MaxValue, new Vector3(1e20f, 0f, 0f), out _);
huge.ApplyRetailView(Vector3.Zero, visible, true);
Assert.True(Assert.Single(huge.EnumerateEmitters()).ViewEligible);
ParticleSystem negative = Spawn(-1f, Vector3.Zero, out _);
negative.ApplyRetailView(Vector3.Zero, visible, true);
Assert.False(Assert.Single(negative.EnumerateEmitters()).ViewEligible);
ParticleSystem zero = Spawn(0f, Vector3.Zero, out _);
zero.ApplyRetailView(Vector3.Zero, visible, true);
Assert.True(Assert.Single(zero.EnumerateEmitters()).ViewEligible);
ParticleSystem nan = Spawn(float.NaN, new Vector3(100f, 0f, 0f), out _);
nan.ApplyRetailView(Vector3.Zero, visible, true);
Assert.True(Assert.Single(nan.EnumerateEmitters()).ViewEligible);
ParticleSystem infinity = Spawn(float.PositiveInfinity, new Vector3(float.MaxValue, 0f, 0f), out _);
infinity.ApplyRetailView(Vector3.Zero, visible, true);
Assert.True(Assert.Single(infinity.EnumerateEmitters()).ViewEligible);
}
[Fact]
public void InfiniteEmitter_DegradedTimeFreezesParticleAgeWithoutCatchUp()
{
var sys = MakeSystem();
var desc = new EmitterDesc
{
DatId = 0x32000077u,
Type = ParticleType.Still,
MaxDegradeDistance = 5f,
MaxParticles = 1,
InitialParticles = 1,
TotalParticles = 0,
TotalDuration = 0f,
LifetimeMin = 10f,
LifetimeMax = 10f,
StartSize = 1f,
EndSize = 1f,
StartAlpha = 1f,
EndAlpha = 1f,
};
int handle = sys.SpawnEmitter(desc, Vector3.Zero, attachedObjectId: 9u);
sys.UpdateEmitterOwnerCell(handle, 0x01010009u);
var visible = new HashSet<uint> { 0x01010009u };
sys.ApplyRetailView(Vector3.Zero, visible, true);
sys.Tick(1f);
Assert.Equal(1f, Assert.Single(sys.EnumerateLive()).Emitter.Particles[0].Age, 4);
sys.ApplyRetailView(new Vector3(6f, 0f, 0f), visible, true);
sys.Tick(5f);
Assert.True(Assert.Single(sys.EnumerateEmitters()).DegradedOut);
sys.ApplyRetailView(Vector3.Zero, visible, true);
sys.Tick(1f);
ParticleEmitter emitter = Assert.Single(sys.EnumerateEmitters());
Assert.False(emitter.DegradedOut);
Assert.Equal(2f, emitter.Particles[0].Age, 4);
}
[Fact]
public void ApplyRetailView_ExaminationAndPassOwnedEmittersBypassWorldGate()
{
var sys = MakeSystem();
var desc = new EmitterDesc
{
DatId = 0x3200007Cu,
Type = ParticleType.Still,
MaxDegradeDistance = 1f,
MaxParticles = 1,
};
sys.SpawnEmitter(
desc,
new Vector3(100f, 0f, 0f),
visibilityPolicy: ParticleVisibilityPolicy.Examination);
sys.SpawnEmitter(
desc,
new Vector3(100f, 0f, 0f),
renderPass: ParticleRenderPass.SkyPreScene,
visibilityPolicy: ParticleVisibilityPolicy.PassOwned);
sys.ApplyRetailView(Vector3.Zero, new HashSet<uint>(), false);
Assert.All(sys.EnumerateEmitters(), emitter => Assert.True(emitter.ViewEligible));
}
[Fact]
public void FiniteEmitter_DegradedBranchExpiresAndRemovesIt()
{
var sys = MakeSystem();
var desc = new EmitterDesc
{
DatId = 0x32000078u,
Type = ParticleType.Still,
MaxDegradeDistance = 1f,
MaxParticles = 1,
InitialParticles = 1,
TotalParticles = 1,
LifetimeMin = 0.5f,
LifetimeMax = 0.5f,
StartSize = 1f,
EndSize = 1f,
StartAlpha = 1f,
EndAlpha = 1f,
};
int handle = sys.SpawnEmitter(desc, Vector3.Zero, attachedObjectId: 10u);
sys.UpdateEmitterOwnerCell(handle, 0x0101000Au);
sys.ApplyRetailView(
new Vector3(2f, 0f, 0f),
new HashSet<uint> { 0x0101000Au },
true);
sys.Tick(1f);
Assert.Equal(1, sys.ActiveEmitterCount);
Assert.True(Assert.Single(sys.EnumerateEmitters()).Finished);
sys.Tick(0.01f);
Assert.Equal(0, sys.ActiveEmitterCount);
}
[Fact]
public void FiniteEmitter_DegradedBranchDoesNotStopBeforeAuthoredLimit()
{
var sys = MakeSystem();
int handle = sys.SpawnEmitter(
new EmitterDesc
{
DatId = 0x3200007Du,
Type = ParticleType.Still,
EmitterKind = ParticleEmitterKind.BirthratePerSec,
Birthrate = 10f,
MaxDegradeDistance = 1f,
MaxParticles = 4,
InitialParticles = 1,
TotalParticles = 4,
TotalDuration = 100f,
LifetimeMin = 50f,
LifetimeMax = 50f,
},
Vector3.Zero,
attachedObjectId: 15u);
sys.UpdateEmitterOwnerCell(handle, 0x0101000Fu);
sys.ApplyRetailView(
new Vector3(2f, 0f, 0f),
new HashSet<uint> { 0x0101000Fu },
true);
sys.Tick(1f);
ParticleEmitter emitter = Assert.Single(sys.EnumerateEmitters());
Assert.False(emitter.Finished);
Assert.Equal(1, emitter.ActiveCount);
Assert.Equal(1, emitter.TotalEmitted);
}
[Fact]
public void FiniteEmitter_DegradedDueEmissionRecordsBothRetailCounters()
{
var sys = MakeSystem();
int handle = sys.SpawnEmitter(
new EmitterDesc
{
DatId = 0x3200007Eu,
Type = ParticleType.Still,
EmitterKind = ParticleEmitterKind.BirthratePerSec,
Birthrate = 0.5f,
MaxDegradeDistance = 1f,
MaxParticles = 4,
TotalParticles = 3,
TotalDuration = 100f,
LifetimeMin = 5f,
LifetimeMax = 5f,
},
Vector3.Zero,
attachedObjectId: 16u);
sys.UpdateEmitterOwnerCell(handle, 0x01010010u);
sys.ApplyRetailView(
new Vector3(2f, 0f, 0f),
new HashSet<uint> { 0x01010010u },
true);
sys.Tick(0.25f);
ParticleEmitter beforeDue = Assert.Single(sys.EnumerateEmitters());
Assert.Equal(0, beforeDue.ActiveCount);
Assert.Equal(0, beforeDue.TotalEmitted);
Assert.False(beforeDue.Finished);
sys.Tick(0.5f);
ParticleEmitter afterDue = Assert.Single(sys.EnumerateEmitters());
Assert.Equal(1, afterDue.ActiveCount);
Assert.Equal(1, afterDue.TotalEmitted);
Assert.False(afterDue.Finished);
Assert.Empty(sys.EnumerateLive());
}
[Fact]
public void FiniteEmitter_DegradedRecordedCountPersistsUntilOwnerTeardownLikeRetail()
{
var sys = MakeSystem();
int handle = sys.SpawnEmitter(
new EmitterDesc
{
DatId = 0x3200007Fu,
Type = ParticleType.Still,
EmitterKind = ParticleEmitterKind.BirthratePerSec,
Birthrate = 0.5f,
MaxDegradeDistance = 1f,
MaxParticles = 1,
TotalParticles = 1,
TotalDuration = 100f,
LifetimeMin = 5f,
LifetimeMax = 5f,
},
Vector3.Zero,
attachedObjectId: 17u);
sys.UpdateEmitterOwnerCell(handle, 0x01010011u);
sys.ApplyRetailView(
new Vector3(2f, 0f, 0f),
new HashSet<uint> { 0x01010011u },
true);
sys.Tick(0.75f);
ParticleEmitter stopped = Assert.Single(sys.EnumerateEmitters());
Assert.True(stopped.Finished);
Assert.Equal(1, stopped.ActiveCount);
Assert.Empty(sys.EnumerateLive());
// Retail RecordParticleEmission (0x0051C870) increments
// num_particles without allocating a PhysicsPart. The already-stopped
// UpdateParticles branch (0x0051D180) therefore keeps returning true
// until the owning physics object explicitly destroys the emitter.
sys.Tick(10f);
Assert.True(sys.IsEmitterAlive(handle));
Assert.Equal(1, Assert.Single(sys.EnumerateEmitters()).ActiveCount);
Assert.Empty(sys.EnumerateLive());
sys.StopEmitter(handle, fadeOut: false);
Assert.False(sys.IsEmitterAlive(handle));
}
[Fact] [Fact]
public void UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor() public void UpdateEmitterAnchor_AttachLocal_ParticlePositionFollowsLiveAnchor()
{ {
@ -392,9 +778,13 @@ public sealed class ParticleSystemTests
Flags = EmitterFlags.AttachLocal | EmitterFlags.Billboard, Flags = EmitterFlags.AttachLocal | EmitterFlags.Billboard,
MaxParticles = 1, MaxParticles = 1,
InitialParticles = 1, InitialParticles = 1,
LifetimeMin = 100f, LifetimeMax = 100f, Lifespan = 100f, LifetimeMin = 100f,
StartSize = 1f, EndSize = 1f, LifetimeMax = 100f,
StartAlpha = 1f, EndAlpha = 1f, Lifespan = 100f,
StartSize = 1f,
EndSize = 1f,
StartAlpha = 1f,
EndAlpha = 1f,
// Zero motion + zero offset so position == origin == AnchorPos. // Zero motion + zero offset so position == origin == AnchorPos.
}; };
int handle = sys.SpawnEmitter(desc, anchor: new Vector3(10, 0, 0)); int handle = sys.SpawnEmitter(desc, anchor: new Vector3(10, 0, 0));
@ -425,9 +815,13 @@ public sealed class ParticleSystemTests
Flags = EmitterFlags.Billboard, // NO AttachLocal Flags = EmitterFlags.Billboard, // NO AttachLocal
MaxParticles = 1, MaxParticles = 1,
InitialParticles = 1, InitialParticles = 1,
LifetimeMin = 100f, LifetimeMax = 100f, Lifespan = 100f, LifetimeMin = 100f,
StartSize = 1f, EndSize = 1f, LifetimeMax = 100f,
StartAlpha = 1f, EndAlpha = 1f, Lifespan = 100f,
StartSize = 1f,
EndSize = 1f,
StartAlpha = 1f,
EndAlpha = 1f,
}; };
int handle = sys.SpawnEmitter(desc, anchor: new Vector3(10, 0, 0)); int handle = sys.SpawnEmitter(desc, anchor: new Vector3(10, 0, 0));
sys.Tick(0.01f); sys.Tick(0.01f);
@ -473,9 +867,13 @@ public sealed class ParticleSystemTests
Birthrate = 0.05f, // 50ms minimum between emits Birthrate = 0.05f, // 50ms minimum between emits
EmitRate = 0f, // disable the EmitRate fallback path EmitRate = 0f, // disable the EmitRate fallback path
MaxParticles = 100, MaxParticles = 100,
LifetimeMin = 100f, LifetimeMax = 100f, Lifespan = 100f, LifetimeMin = 100f,
StartSize = 1f, EndSize = 1f, LifetimeMax = 100f,
StartAlpha = 1f, EndAlpha = 1f, Lifespan = 100f,
StartSize = 1f,
EndSize = 1f,
StartAlpha = 1f,
EndAlpha = 1f,
}; };
sys.SpawnEmitter(desc, Vector3.Zero); sys.SpawnEmitter(desc, Vector3.Zero);

View file

@ -147,6 +147,29 @@ public class LandblockLoaderTests
Assert.All(idsB, id => Assert.Equal(0xC0u, (id >> 24) & 0xFFu)); Assert.All(idsB, id => Assert.Equal(0xC0u, (id >> 24) & 0xFFu));
} }
[Fact]
public void BuildEntitiesFromInfo_AssignsOutdoorEffectCellWithoutChangingRenderParent()
{
var info = new LandBlockInfo
{
Objects =
{
new Stab
{
Id = 0x01000001u,
Frame = new Frame { Origin = new Vector3(25f, 49f, 0f) },
},
},
};
WorldEntity entity = Assert.Single(
LandblockLoader.BuildEntitiesFromInfo(info, 0xA9B40000u));
// x cell 1, y cell 2 => 1 + 1*8 + 2 = 11 (0x0B).
Assert.Equal(0xA9B4000Bu, entity.EffectCellId);
Assert.Null(entity.ParentCellId);
}
[Fact] [Fact]
public void BuildEntitiesFromInfo_LegacyZeroLandblockId_StartsAtOne() public void BuildEntitiesFromInfo_LegacyZeroLandblockId_StartsAtOne()
{ {

View file

@ -45,7 +45,8 @@ public sealed class SettingsStoreTests : System.IDisposable
FieldOfView: 100f, FieldOfView: 100f,
Gamma: 1.4f, Gamma: 1.4f,
ShowFps: true, ShowFps: true,
Quality: AcDream.UI.Abstractions.Settings.QualityPreset.Ultra); Quality: AcDream.UI.Abstractions.Settings.QualityPreset.Ultra,
ParticleRange: ParticleRange.Extended);
store.SaveDisplay(original); store.SaveDisplay(original);
var loaded = store.LoadDisplay(); var loaded = store.LoadDisplay();
@ -83,6 +84,37 @@ public sealed class SettingsStoreTests : System.IDisposable
Assert.Equal(DisplaySettings.Default.Fullscreen, loaded.Fullscreen); Assert.Equal(DisplaySettings.Default.Fullscreen, loaded.Fullscreen);
Assert.Equal(DisplaySettings.Default.VSync, loaded.VSync); Assert.Equal(DisplaySettings.Default.VSync, loaded.VSync);
Assert.Equal(DisplaySettings.Default.FieldOfView, loaded.FieldOfView); Assert.Equal(DisplaySettings.Default.FieldOfView, loaded.FieldOfView);
Assert.Equal(ParticleRange.Retail, loaded.ParticleRange);
}
[Fact]
public void LoadDisplay_invalid_particle_range_falls_back_to_retail()
{
File.WriteAllText(_tempPath, """
{
"version": 2,
"display": { "particleRange": "Unlimited" }
}
""");
var loaded = new SettingsStore(_tempPath).LoadDisplay();
Assert.Equal(ParticleRange.Retail, loaded.ParticleRange);
}
[Fact]
public void LoadDisplay_numeric_undefined_particle_range_falls_back_to_retail()
{
File.WriteAllText(_tempPath, """
{
"version": 2,
"display": { "particleRange": "999" }
}
""");
var loaded = new SettingsStore(_tempPath).LoadDisplay();
Assert.Equal(ParticleRange.Retail, loaded.ParticleRange);
} }
[Fact] [Fact]