acdream/docs/superpowers/specs/2026-05-13-phase-c1.5b-design.md
Erik 1e3c33b4db docs(vfx #C.1.5b): design + plan for issue #56 + EnvCell DefaultScript
Two-slice phase:
- Slice A: ParticleHookSink applies CreateParticleHook.PartIndex via
  SetupPartTransforms.Compute(setup.PlacementFrames). Closes #56.
- Slice B: drop EntityScriptActivator's ServerGuid==0 guard so
  dat-hydrated EnvCell statics + exterior stabs fire DefaultScript.

Key reality discovery folded into the spec §3: EnvCell.StaticObjects
are already WorldEntities (via GameWindow.BuildInteriorEntitiesForStreaming),
so no synthetic-ID scheme + no new walker class needed — the handoff's
§4 Q1/Q2 options are mooted by entity.Id being collision-free.

Doc-drift fixes from C.1.5a folded into §8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:51:44 +02:00

24 KiB
Raw Blame History

Phase C.1.5b — issue #56 (per-part collapse) + EnvCell static DefaultScript dispatch

Created: 2026-05-13. Author: Claude (lead engineer/architect). Phase: C.1.5b (second of two slices; C.1.5a portal-PES wiring shipped 2026-05-11 in merge 88bda12). Parent plan: docs/plans/2026-04-27-phase-c1-pes-particles.md §C.1.5. Handoff doc: docs/plans/2026-05-12-phase-c1.5b-handoff.md.


§1 Goals

Two coupled slices in one phase, in this order:

Slice A — ParticleHookSink honors CreateParticleHook.PartIndex for static entities. Closes issue #56. The Holtburg Town network portal's 10-emitter script currently collapses every emitter to the entity root, producing a compressed, partially-ground-buried swirl. The fix is to precompute each Setup part's resting transform at spawn time and apply it to the hook offset before spawning the particle.

Slice B — EntityScriptActivator fires Setup.DefaultScript for dat-hydrated entities too. Right now the activator gates on entity.ServerGuid != 0, which means EnvCell static objects (interior fireplaces, inn decorations, exterior stabs like cottage chimneys) — which have no server guid because they come from the dat file, not the network — never get their DefaultScript fired. Drop the guard, key by entity.Id when ServerGuid == 0, and wire OnCreate / OnRemove calls into GpuWorldState's dat-hydration paths.

Plus a visual confirmation pass for the animation-hook particle path (already shipped in C.1; just needs a sanity check by casting a spell on +Acdream).

Acceptance

Visual verification at three retail-side-by-side locations in/near Holtburg:

  1. Town network portal (the C.1.5a verification site): swirl extends vertically through the portal arch with retail-like shape; no ground-burial; emitters distributed across the portal Setup's parts.
  2. Holtburg Inn fireplace (interior, EnvCell static): flame particles match retail's pattern and position over the firebox.
  3. Cottage chimney (exterior stab — TBD which cottage): smoke particles match retail.
  4. Animation-hook spell cast on +Acdream: cast-anim particle effect matches retail.

§2 Scope

In:

  • New helper AcDream.Core.Meshing.SetupPartTransforms.Compute(Setup) that walks PlacementFrames[Resting] → fallback [Default] → first available and returns IReadOnlyList<Matrix4x4> (one transform per part).
  • ParticleHookSink.SetEntityPartTransforms(uint entityId, IReadOnlyList<Matrix4x4> partTransforms)
    • a backing _partTransformsByEntity map cleared by StopAllForEntity.
  • ParticleHookSink.SpawnFromHook applies partTransforms[partIndex] to the hook offset before rotating to world space.
  • EntityScriptActivator resolver signature changes from Func<WorldEntity, uint> to Func<WorldEntity, ScriptActivationInfo?> so both ScriptId and PartTransforms come from one dat lookup.
  • EntityScriptActivator.OnCreate keys by entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id. Same activator handles both server-spawned and dat-hydrated entities — no new class.
  • EntityScriptActivator.OnRemove(uint key) — caller picks the key.
  • GpuWorldState wires the activator into four more places: AddLandblock (dat-hydrated entities only — filter by ServerGuid==0 to avoid double-firing pending live entities), AddEntitiesToExistingLandblock (the just-promoted entities — all dat-hydrated by construction), RemoveLandblock (dat-hydrated entities only), and RemoveEntitiesFromLandblock (dat-hydrated entities only).
  • Visual verification at the four sites in §1 Acceptance.

Out:

  • Animated entities (NPCs, monsters, the player). Per-part transforms vary per animation frame and would need a per-tick refresh similar to UpdateEntityAnchor. Deferred to a future phase. The new SetEntityPartTransforms is keyed by entity, so an animated-entity path can later push fresh transforms each tick without changing the contract.
  • Renderer changes. particle.frag stays as-is; bindless migration is N.6 slice 2.
  • WB's re-fire-after-1s loop logic — portal swirls + fireplace flames are persistent (TotalParticles=0 && TotalSeconds=0), no re-fire needed.
  • New emitter types. Reuse existing PES data.

§3 Background

What shipped in C.1.5a (the part we keep)

The mechanism is correct: EntityScriptActivator.OnCreate runs on every server-spawned WorldEntity, resolves Setup.DefaultScript, seeds _particleSink.SetEntityRotation, calls _scriptRunner.Play(scriptId, entity.ServerGuid, entity.Position). Multi-hook scripts dispatch at their correct StartTime offsets. Despawn cleanup works.

What's broken (issue #56)

ParticleHookSink.SpawnFromHook at lines 176-217:

var rotation = _rotationByEntity.TryGetValue(entityId, out var rot)
    ? rot : Quaternion.Identity;
var anchor = worldPos + Vector3.Transform(offset, rotation);

The hook author intended offset to be in part-local space — i.e., relative to the mesh part identified by cph.PartIndex — so the geometry retail computes is:

anchor = entityWorldPos + entityRotation × (partFrame.Origin + partFrame.Orientation × hookOffset)

Our sink drops the part transform multiplication. For the Holtburg portal (entity 0x7A9B405B, script 0x3300126D, 10 hooks distributed across the portal Setup's parts), every emitter lands at the entity root. Visible symptom: swirl partially buried, lateral spread compressed.

Where part transforms come from (static entities)

For static entities, per-part transforms live in setup.PlacementFrames[Placement.Resting] (fallback [Default], fallback first available — same priority chain SetupMesh.Flatten at lines 36-50 already uses). Per part i:

Matrix4x4.CreateScale(setup.DefaultScale[i])
  * Matrix4x4.CreateFromQuaternion(placementFrame.Frames[i].Orientation)
  * Matrix4x4.CreateTranslation(placementFrame.Frames[i].Origin)

DefaultScale defaults to Vector3.One when the list is shorter than Parts.Count.

Where EnvCell statics come from (slice B)

Major discovery from this design pass — the handoff's §4 Q1/Q2 are mooted: GameWindow.BuildInteriorEntitiesForStreaming at lines 5030-5135 already hydrates EnvCell StaticObjects as WorldEntity instances with stable entity.Id in the 0x40xxxxxx range:

uint interiorIdBase = 0x40000000u | (landblockId & 0x00FFFF00u);
// ... for each EnvCell, for each stab in envCell.StaticObjects ...
var hydrated = new WorldEntity {
    Id = interiorIdBase + localCounter++,
    SourceGfxObjOrSetupId = stab.Id,   // 0x02000000 → Setup-based
    Position = stab.Frame.Origin + lbOffset,
    Rotation = stab.Frame.Orientation,
    MeshRefs = meshRefs,
    ParentCellId = envCellId,
};

These flow into GpuWorldState.AddLandblock as part of landblock.Entities, sit there with ServerGuid == 0, and currently never have their Setup.DefaultScript fired. The activator's existing ServerGuid == 0 → return; guard intentionally skips them (atlas-tier exemption inherited from EntitySpawnAdapter).

Three architectural consequences:

  1. No synthetic ID scheme needed. entity.Id is already collision-free with server guids (live spawns use 0x500000xx0x7Fxxxxxx), anonymous emitter IDs (0x80000000u+), and the four entity-id ranges (0x40xxxxxx interior / 0x80xxxxxx scenery / etc) all live in disjoint high-byte slices.
  2. No new EnvCellStaticActivator class. The existing EntityScriptActivator handles both server-spawned and dat-hydrated entities once the guard is keyed-by-id-when-zero.
  3. No new walker. BuildInteriorEntitiesForStreaming is the walker — it already happens. We just need the OnCreate fire-site in GpuWorldState's AddLandblock / AddEntitiesToExistingLandblock.

The handoff §4 wrote three options (α piggyback / β new class / γ extend activator) under the assumption that EnvCell statics were NOT WorldEntities. This reality discovery collapses all three to a simpler answer that none of them anticipated.

§4 Architecture

Slice A: part-transform pipeline

EntityScriptActivator.OnCreate(entity)
├─ key = entity.ServerGuid != 0 ? entity.ServerGuid : entity.Id
├─ info = resolver(entity)                   // ScriptActivationInfo? {ScriptId, PartTransforms}
├─ if (info is null || info.ScriptId == 0) return
├─ _particleSink.SetEntityRotation(key, entity.Rotation)
├─ _particleSink.SetEntityPartTransforms(key, info.PartTransforms)  // NEW
└─ _scriptRunner.Play(info.ScriptId, key, entity.Position)

ParticleHookSink.SpawnFromHook(entityId, worldPos, ..., partIndex, ...)
├─ rotation = _rotationByEntity[entityId] ?? Quaternion.Identity
├─ partTransform = (partTransforms != null && partIndex >= 0 && partIndex < Count)
│                  ? partTransforms[partIndex] : Matrix4x4.Identity
├─ partLocal = Vector3.Transform(offset, partTransform)
├─ anchor = worldPos + Vector3.Transform(partLocal, rotation)
└─ _system.SpawnEmitterById(...)

Slice B: dat-hydration fire-sites

GpuWorldState.AddLandblock(landblock)
├─ merge pending live entities (existing)
├─ _loaded[id] = landblock (existing)
├─ _wbSpawnAdapter?.OnLandblockLoaded(...) (existing)
├─ foreach entity in landblock.Entities where ServerGuid == 0:        // NEW
│     _entityScriptActivator?.OnCreate(entity)
└─ RebuildFlatView() (existing)

GpuWorldState.AddEntitiesToExistingLandblock(landblockId, entities)
├─ canonicalize + merge (existing)
├─ _wbSpawnAdapter?.OnLandblockLoaded(...) (existing)
├─ foreach entity in entities:                                        // NEW
│     _entityScriptActivator?.OnCreate(entity)        // all dat-hydrated
└─ RebuildFlatView() (existing)

GpuWorldState.RemoveLandblock(landblockId)
├─ _wbSpawnAdapter?.OnLandblockUnloaded(...) (existing)
├─ rescue persistent (existing)
├─ foreach entity in lb.Entities where ServerGuid == 0:               // NEW
│     _entityScriptActivator?.OnRemove(entity.Id)
└─ remove from _loaded, RebuildFlatView (existing)

GpuWorldState.RemoveEntitiesFromLandblock(landblockId)
├─ _wbSpawnAdapter?.OnLandblockUnloaded(...) (existing)
├─ _onLandblockUnloaded?.Invoke(canonical) (existing — Tier 1 cache sweep)
├─ foreach entity in lb.Entities where ServerGuid == 0:               // NEW
│     _entityScriptActivator?.OnRemove(entity.Id)
└─ replace lb.Entities with empty list, RebuildFlatView (existing)

The ServerGuid == 0 filter avoids double-firing OnCreate on live entities that came via AppendLiveEntity and got pending-bucket-merged in AddLandblock. Their OnCreate already fired at AppendLiveEntity time.

Resolver evolution

C.1.5a resolver:

Func<WorldEntity, uint> defaultScriptResolver  // returns scriptId or 0

C.1.5b resolver:

Func<WorldEntity, ScriptActivationInfo?> activationResolver  // returns null on miss

Where ScriptActivationInfo is a small record in AcDream.App.Rendering.Vfx:

public sealed record ScriptActivationInfo(
    uint ScriptId,
    IReadOnlyList<Matrix4x4> PartTransforms);

Production lambda in GameWindow.OnLoad (replaces the C.1.5a one):

entity =>
{
    try
    {
        var setup = _dats.Get<Setup>(entity.SourceGfxObjOrSetupId);
        if (setup is null) return null;
        uint scriptId = setup.DefaultScript.DataId;
        if (scriptId == 0) return null;
        var parts = AcDream.Core.Meshing.SetupPartTransforms.Compute(setup);
        return new ScriptActivationInfo(scriptId, parts);
    }
    catch
    {
        return null;
    }
}

One dat lookup → both pieces of info. The Setup is cached by DatCollection, so even hot-path scenery firing with no DefaultScript stays O(1).

Helper: SetupPartTransforms.Compute

New static helper in AcDream.Core.Meshing (next to SetupMesh):

public static class SetupPartTransforms
{
    /// <summary>
    /// Compute the per-part static transforms for a Setup using its
    /// PlacementFrames. For each part i, the returned matrix is the
    /// transform from part-local to setup-local space at the Setup's
    /// resting pose. Mirrors SetupMesh.Flatten's pose-source priority:
    /// PlacementFrames[Resting] → [Default] → first available.
    /// Returns an empty list when the Setup has no PlacementFrames
    /// (caller falls back to "no part transforms applied").
    /// </summary>
    public static IReadOnlyList<Matrix4x4> Compute(Setup setup);
}

This deliberately mirrors the pose-source priority in SetupMesh.Flatten so a part's particle anchor matches its visible rest position. (If the renderer's pose source ever diverges from this resolver, particles will visibly drift — keep them in lockstep.)

For animated entities, the renderer's AnimatedEntityState computes per-frame part transforms; a future "animated DefaultScript" path would publish those each tick via the same SetEntityPartTransforms seam. Out of scope for C.1.5b.

§5 Data + lifecycle invariants

Concern Behavior
Server-spawned entity spawn AppendLiveEntityOnCreate (existing). Keys by ServerGuid.
Server-spawned entity despawn RemoveEntityByServerGuidOnRemove(serverGuid) (existing).
Dat-hydrated entity load (initial) AddLandblockOnCreate for each ServerGuid==0 entity. Keys by entity.Id.
Dat-hydrated entity load (promotion) AddEntitiesToExistingLandblockOnCreate for each entity in the new batch. Keys by entity.Id.
Dat-hydrated entity unload (full LB) RemoveLandblockOnRemove(entity.Id) for each ServerGuid==0 entity.
Dat-hydrated entity unload (Near→Far demotion) RemoveEntitiesFromLandblockOnRemove(entity.Id) for each ServerGuid==0 entity.
Pending live entity merged into AddLandblock OnCreate already fired at AppendLiveEntity; filtered out by ServerGuid != 0.
Persistent live entity rescued from RemoveLandblock Not unloaded; its script continues. Filtered out by ServerGuid != 0.
PartIndex out of bounds Sink falls back to Matrix4x4.Identity for that part (no part transform applied, offset stays in entity-local frame as before).
Setup with empty PlacementFrames Resolver returns empty PartTransforms list; sink falls back to Identity for every part. Equivalent to pre-C.1.5b behavior.
Resolver throws Lambda's try/catch returns null; activator no-ops.
Same script re-fired on dedupe PhysicsScriptRunner.Play replaces prior instance (existing C.1 behavior). Visual: script restarts from t=0. Avoided here because we filter dat-hydrated entities by ServerGuid==0 — they're not double-fired.

Idempotency

  • Duplicate OnCreate for same key → script restarts (existing dedupe).
  • Duplicate OnRemove for same key → no-op.
  • OnRemove for never-spawned key → no-op.
  • LB unload immediately followed by LB load → entities get fresh entity.Id (localCounter resets per-call) but the keys are computed deterministically from landblockId + iteration order so a re-entered LB gets identical keys. Script restarts cleanly because OnRemove fired during the unload.

§6 Testing

Unit tests — new

  1. SetupPartTransforms_ResolvesRestingPlacement_WhenAvailable — Setup with PlacementFrames[Resting] containing 2 parts; assert returned list has 2 matrices matching the resting frames.
  2. SetupPartTransforms_FallsBackToDefault_WhenRestingMissing — Setup with only PlacementFrames[Default]; assert it's used.
  3. SetupPartTransforms_ReturnsEmpty_WhenNoPlacementFrames — Setup with empty PlacementFrames dict; assert empty list.
  4. SetupPartTransforms_AppliesDefaultScale_WhenPresent — Setup with DefaultScale[0] = (2, 2, 2); assert the matrix scales by 2.
  5. ParticleHookSink_AppliesPartTransform_WhenRegistered — register part transforms [Identity, Translation(0,0,1)]; fire a CreateParticleHook with PartIndex=1, Offset=(1,0,0); assert spawned particle world position is (1, 0, 1).
  6. ParticleHookSink_FallsBackToIdentity_WhenPartIndexOutOfBounds — register 2 part transforms; fire hook with PartIndex=99; assert spawned at root + offset (no buried-by-bad-matrix).
  7. EntityScriptActivator_KeysByEntityId_WhenServerGuidZero — dat-hydrated entity with ServerGuid=0, Id=0x40A9B401; fire OnCreate; assert script runner saw entityId=0x40A9B401.
  8. EntityScriptActivator_PassesPartTransformsToSink — resolver returns non-empty PartTransforms; assert sink's SetEntityPartTransforms was called with the matching list.
  9. EntityScriptActivator_OnRemove_StopsByGivenKey — call OnRemove(0x40A9B401); assert runner + sink both got that key.

Unit tests — updated

The 4 existing EntityScriptActivatorTests are updated for the new resolver signature (_ => 0xAAu_ => new ScriptActivationInfo(0xAAu, Array.Empty<Matrix4x4>())). Test names and assertions stay the same.

Integration tests — GpuWorldState wiring

  1. GpuWorldState_AddLandblock_FiresActivatorForDatHydrated — construct GpuWorldState with a fake activator (recording mock); add a landblock with one ServerGuid==0 entity; assert OnCreate fired exactly once.
  2. GpuWorldState_AddLandblock_DoesNotDoubleFire_OnPendingMerge — AppendLiveEntity with ServerGuid=0xCAFE (one OnCreate); then AddLandblock for the same canonical id; assert OnCreate fired only once total for the live entity.
  3. GpuWorldState_RemoveLandblock_FiresOnRemoveForDatHydrated — AddLandblock with a dat-hydrated entity, then RemoveLandblock; assert OnRemove fired with entity.Id.
  4. GpuWorldState_AddEntitiesToExistingLandblock_FiresActivator — promotion path; assert OnCreate fires for each promoted entity.
  5. GpuWorldState_RemoveEntitiesFromLandblock_FiresOnRemove — demotion path; assert OnRemove fires for each removed dat-hydrated entity.

Existing GpuWorldStateTests may need a minor update if any assert on constructor arity (the resolver doesn't change shape — same 4 ctor params).

Visual verification (acceptance gate)

Procedure (per CLAUDE.md "Visual verification workflow"):

  1. dotnet build green.
  2. dotnet test green.
  3. Launch live client with ACDREAM_DUMP_PLAYSCRIPT=1.
  4. Site 1 — Holtburg Town network portal (same site as C.1.5a): user walks +Acdream to the portal arch. Compare swirl vertical extent + lateral spread to retail. Pass: no ground-burial, distinct columns of emission visible across the arch.
  5. Site 2 — Holtburg Inn fireplace (interior, EnvCell static): user walks into the inn, stands near the fireplace. Pass: flame particles emit from the firebox at retail-matching height/density.
  6. Site 3 — Cottage chimney (exterior stab): user finds a Holtburg cottage with smoke in retail; same cottage in acdream should now show smoke. Pass: smoke column matches retail.
  7. Site 4 — Spell cast on +Acdream: user casts a spell, optionally in a safe spot. Pass: cast-anim particles match retail.

Diagnostic: ACDREAM_DUMP_PLAYSCRIPT=1 prints every [pes] Play: line — if a site doesn't show particles, check the log to see whether the script fired and with what scriptId.

§7 Risk + rollback

Slice A risks:

  • SetupPartTransforms.Compute returns a list whose length doesn't match setup.Parts.Count. Mitigation: sink's per-index bounds check falls back to Identity; no buried particles, just reverts to C.1.5a behavior for the over-indexed hook.
  • Wrong pose source chosen (Resting vs Default). Mitigation: mirror SetupMesh.Flatten's priority chain exactly so renderer + particle anchor stay in lockstep. If they ever diverge, particles drift visibly; user spot-checks at the portal.

Slice B risks:

  • Firing OnCreate for EVERY dat-hydrated entity (scenery counts ~thousands per landblock at radius=4) becomes a perf hit. Mitigation: resolver is one cached DatCollection.Get<Setup> per entity — already amortized. Most entities have DefaultScript.DataId == 0, resolver returns null, OnCreate no-ops in ~1µs. Per-landblock-load cost: tens of µs, dwarfed by mesh upload + RebuildFlatView. Measured if [pes] Play: line spam appears in launch.log.
  • Filter ServerGuid==0 is too aggressive — misses some valid case. Mitigation: every entity with ServerGuid != 0 came through AppendLiveEntity (verified by RelocateEntity's if (entity.ServerGuid == 0) return; guard at GpuWorldState.cs:204), so they already had OnCreate fired. No miss.
  • Idempotency edge case: rapid LB load/unload cycles produce repeated Play → Stop → Play. Mitigation: existing PhysicsScriptRunner dedupe handles re-Play; this is the same as a server retriggering a PlayScript opcode.

Rollback path: revert the spec's commits; the C.1.5a EntityScriptActivator keeps working for live entities exactly as before. No data migrations.

§8 Doc-drift fixes from C.1.5a (folded in)

The handoff §9 surfaced three trivial doc-drift items from C.1.5a. Folded here for the record:

  1. C.1.5a spec §4 ("fifth optional parameter") was wrong — the activator is actually GpuWorldState's fourth optional parameter (verified at GpuWorldState.cs:63: wbSpawnAdapter, wbEntitySpawnAdapter, onLandblockUnloaded, entityScriptActivator).
  2. C.1.5a spec §4 ("~50 lines") was an estimate; the file shipped at 93 lines including doc comments. Slice A adds the part-transform call + slice B drops the ServerGuid == 0 guard, so the file will land at ~100110 lines after this phase.
  3. GpuWorldState.AddEntitiesToExistingLandblock will fire the activator in slice B (the handoff said it currently doesn't and noted "no-op today because promotion-tier entities are atlas-tier"). With slice B, atlas-tier entities WITH DefaultScript set will now activate. Per the architecture comment at GpuWorldState.cs:384-391, this path handles dat-static stabs/buildings — exactly the case slice B targets.

§9 Implementation notes

  • File touches: ParticleHookSink.cs (+~30 lines), EntityScriptActivator.cs (+~10 lines, -~5 lines), GpuWorldState.cs (+~12 lines, 4 fire-sites), GameWindow.cs (resolver lambda update, ~10 lines), new SetupPartTransforms.cs (~50 lines), updated EntityScriptActivatorTests.cs (4 ctor-signature updates + new tests), new SetupPartTransformsTests.cs (~80 lines, 4 tests), new ParticleHookSinkTests.cs additions or new file (~60 lines, 2 tests), new GpuWorldStateActivatorTests.cs (~120 lines, 5 integration tests).
  • Estimated effort: ~1 day.
  • Commit cadence: four commits land this phase cleanly — (1) SetupPartTransforms helper + tests, (2) ParticleHookSink part-transform support + tests, (3) EntityScriptActivator resolver refactor + ServerGuid guard relaxation + tests, (4) GpuWorldState fire-site wiring + tests + production lambda update + the C.1.5a doc-drift comment for AddEntitiesToExistingLandblock. Each commit dotnet test green. Visual verification after all four land.
  • Roadmap update: on ship, add a "Phase C.1.5b SHIPPED 2026-05-13" entry to docs/plans/2026-04-11-roadmap.md; move #56 to "Recently closed" in docs/ISSUES.md.
  • CLAUDE.md update: the "Currently in flight" line at the top of the project-instructions block changes from C.1.5b to the next phase, with the handoff doc reference dropped. Decide the next-phase pointer at verification time.

§10 What's next (post-C.1.5b)

Pending user direction. The roadmap candidate list from docs/plans/2026-04-11-roadmap.md:

  • Triage the chronic open-issue list — #2 (lightning), #4 (sky horizon-glow), #28 (aurora), #29 (cloud thinness), #37 (humanoid coat), #50 (stray tree), #41 (remote-motion blips) — link each to a future phase or downgrade.
  • More Phase C visual-fidelity work (C.2 dynamic point lights, C.3 palette tuning, C.4 double-sided translucent polys).
  • N.6 slice 2 at reduced scope (atlas opportunities only).
  • Perf tiers 2/3 only if sustained 500+ FPS becomes a requirement.

Verification will surface which option the user picks.