From 554ffbb880551327340f8bf23b4d8c78ca25ff31 Mon Sep 17 00:00:00 2001 From: Mathih13 Date: Fri, 4 Sep 2026 23:05:20 +0200 Subject: [PATCH] perf(module): narrow creature tick candidates --- module/src/creatures/cycle/ctx.rs | 4 +- module/src/creatures/cycle/harness.rs | 6 +- module/src/creatures/cycle/mod.rs | 8 +-- module/src/creatures/eventai/mobility.rs | 8 --- module/src/creatures/eventai/mod.rs | 2 +- module/src/creatures/eventai/tables.rs | 5 +- module/src/creatures/tick/mod.rs | 81 ++++++++++++++---------- module/src/world.rs | 5 +- 8 files changed, 64 insertions(+), 55 deletions(-) diff --git a/module/src/creatures/cycle/ctx.rs b/module/src/creatures/cycle/ctx.rs index 27f1e5cda..7ba7dc18c 100644 --- a/module/src/creatures/cycle/ctx.rs +++ b/module/src/creatures/cycle/ctx.rs @@ -1011,8 +1011,8 @@ impl PetSink for CtxWorld<'_> { } impl CreatureWorld for CtxWorld<'_> { - fn awake_creatures(&self, scope: &TickScope) -> TickSweep { - tick::active_cell_creatures(self.ctx, scope) + fn awake_creatures(&self, scope: &TickScope, sense: bool) -> TickSweep { + tick::active_cell_creatures(self.ctx, scope, sense) } fn run_due_world_maintenance(&mut self) -> Vec<(&'static str, u64)> { vec![ diff --git a/module/src/creatures/cycle/harness.rs b/module/src/creatures/cycle/harness.rs index 9fe8975e9..cbf41bc44 100644 --- a/module/src/creatures/cycle/harness.rs +++ b/module/src/creatures/cycle/harness.rs @@ -1432,7 +1432,7 @@ fn native_test_instruction(action: &RuleAction) -> CreatureInstruction { } impl CreatureWorld for Scenario { - fn awake_creatures(&self, _scope: &TickScope) -> TickSweep { + fn awake_creatures(&self, _scope: &TickScope, _sense: bool) -> TickSweep { *self.seen_by_sweep.borrow_mut() = self.snapshot(); let awake = self.awake.borrow(); TickSweep { @@ -6136,8 +6136,8 @@ fn the_production_adapter_is_the_pass_through_the_harness_assumes() { ( "impl CreatureWorld for CtxWorld<'_> {", concat!( - "{ fn awake_creatures(&self, scope: &TickScope) -> TickSweep { ", - "tick::active_cell_creatures(self.ctx, scope) } fn ", + "{ fn awake_creatures(&self, scope: &TickScope, sense: bool) -> TickSweep { ", + "tick::active_cell_creatures(self.ctx, scope, sense) } fn ", "run_due_world_maintenance(&mut self) -> Vec<(&'static str, u64)> { vec![ ", "(\"decay*\", tick::pass_decay(self.ctx) as u64), (\"respawn*\", ", "tick::pass_respawn(self.ctx) as u64), ( \"go_respawn*\", ", diff --git a/module/src/creatures/cycle/mod.rs b/module/src/creatures/cycle/mod.rs index 4597cb583..e07c70552 100644 --- a/module/src/creatures/cycle/mod.rs +++ b/module/src/creatures/cycle/mod.rs @@ -673,9 +673,9 @@ pub(crate) trait CreatureWorld: + PetSink + RegenSink { - /// The creatures near a covered player this firing, plus the pet and in-combat candidate lists - /// the same sweep harvests. Read ONCE per cycle and shared by every pass that scopes to it. - fn awake_creatures(&self, scope: &TickScope) -> TickSweep; + /// The creatures near a covered player this firing, plus candidate lists for passes due at this + /// cadence. Read once per cycle and shared by every pass that scopes to it. + fn awake_creatures(&self, scope: &TickScope, sense: bool) -> TickSweep; /// Corpse decay, creature respawn and gameobject respawn. Not behavior — the cycle only /// SEQUENCES them. Returns the rows each visited, for the operator log. fn run_due_world_maintenance(&mut self) -> Vec<(&'static str, u64)>; @@ -713,7 +713,7 @@ pub(crate) fn run_cycle(w: &mut W, tick: TickContext) -> Cycle active, pets, in_combat, - } = w.awake_creatures(&tick.scope); + } = w.awake_creatures(&tick.scope, tick.sense); let global = tick.scope.runs_global_passes(); let mut rows: Vec<(&'static str, u64)> = Vec::new(); diff --git a/module/src/creatures/eventai/mobility.rs b/module/src/creatures/eventai/mobility.rs index da2531ffe..e4bcc14af 100644 --- a/module/src/creatures/eventai/mobility.rs +++ b/module/src/creatures/eventai/mobility.rs @@ -237,14 +237,6 @@ pub(crate) fn react_state(ctx: &ReducerContext, creature_guid: u64) -> CreatureR } } -pub(crate) fn active_object(ctx: &ReducerContext, creature_guid: u64) -> bool { - ctx.db - .game_creature_ai_state() - .creature_guid() - .find(creature_guid) - .is_some_and(|state| state.active_object) -} - pub(crate) fn force_despawn( ctx: &ReducerContext, creature_guid: u64, diff --git a/module/src/creatures/eventai/mod.rs b/module/src/creatures/eventai/mod.rs index 61f9c515d..e77fcb93e 100644 --- a/module/src/creatures/eventai/mod.rs +++ b/module/src/creatures/eventai/mod.rs @@ -43,7 +43,7 @@ pub(crate) use loader::replace_definition_for_debug; pub(crate) use mobility::summon_life_seq; #[cfg(test)] pub(crate) use mobility::summon_lifetime_after; -pub(crate) use mobility::{active_object, drop_summon_expiry, ranged_posture, react_state}; +pub(crate) use mobility::{drop_summon_expiry, ranged_posture, react_state}; pub use mobility::{ expire_eventai_summon, fire_eventai_forced_despawn, CreatureAiForcedDespawn, CreatureAiSummonExpiry, CreatureAiSummonOrigin, diff --git a/module/src/creatures/eventai/tables.rs b/module/src/creatures/eventai/tables.rs index 3ae63e938..dfcd302b7 100644 --- a/module/src/creatures/eventai/tables.rs +++ b/module/src/creatures/eventai/tables.rs @@ -108,7 +108,10 @@ pub struct CreatureAiSpellMetadata { } /// Creature-wide EventAI state. Edge resets advance its lifecycle identities. Module only. -#[table(accessor = game_creature_ai_state)] +#[table( + accessor = game_creature_ai_state, + index(accessor = by_active_object, btree(columns = [active_object])) +)] pub struct CreatureAiState { #[primary_key] pub creature_guid: u64, diff --git a/module/src/creatures/tick/mod.rs b/module/src/creatures/tick/mod.rs index a2114d74e..40f08c218 100644 --- a/module/src/creatures/tick/mod.rs +++ b/module/src/creatures/tick/mod.rs @@ -15,7 +15,10 @@ use lyracore_shared::spatial; use spacetimedb::{log, reducer, table, ReducerContext, ScheduleAt, Table, Timestamp}; -use crate::{game_aura, game_entity_motion, game_melee_attack, game_world_entity, WorldEntity}; +use crate::{ + game_aura, game_creature_ai_state, game_entity_motion, game_melee_attack, game_world_entity, + WorldEntity, +}; use super::*; @@ -309,52 +312,63 @@ fn active_cell_radius(ctx: &ReducerContext) -> f32 { /// Reuses `helpers::entities_near` (the existing, already-tested `by_grid`-indexed neighborhood query /// from work-item 190 slice 1 — until now unused) per player, so the query is instance-isolated for /// free. A creature absent from this set is DORMANT this tick for every pass that consults it — see -/// the classification in `tick_creatures`'s doc comment. With zero players online the set is empty -/// (every creature dormant); with players online the cost scales with player density, not world size. +/// the classification in `tick_creatures`'s doc comment. The neighborhood-query cost scales with +/// Character density, not world size; authored active objects remain awake without a Character. /// -/// NOTE on the one remaining full-table touch: locating the players themselves still needs -/// `entities.iter().filter(is_player)` — there's no `by_type_mask` (or dedicated players-only) index -/// in the current schema, so this reads every row once. That's the SAME pattern the cycle's aggro -/// phase still uses to build its own player snapshot (this fn doesn't share that one — a further, -/// independent micro-optimization) — but it's a bare bit-check per row, not the expensive per-creature -/// template/faction/stealth logic this item exists to stop running on out-of-range creatures. The -/// rows-visited reduction this item measures is that expensive-logic population, not this cheap scan. +/// Character seeds come from the `entry == 0` index and retain the PLAYER-bit check, so legal type-mask +/// variants remain eligible. Active objects come from their EventAI-state index and are point-read by +/// guid. The residual whole-entity discovery is needed only for pets and exact IN_COMBAT bit masks; +/// it therefore runs on sense firings rather than every movement firing. /// Work-item 229: seeds ONLY from players in instances THIS firing's scope covers — `entities_near` /// is already instance-gated (190 slice 1), so the returned set then contains only covered-instance /// creatures, which scopes patrol/aggro+assist/return/wander without touching their bodies. With /// only the seeded catch-all row, `covers()` is `true` for every player → identical set to pre-229. -pub(crate) fn active_cell_creatures(ctx: &ReducerContext, scope: &TickScope) -> TickSweep { +pub(crate) fn active_cell_creatures( + ctx: &ReducerContext, + scope: &TickScope, + sense: bool, +) -> TickSweep { let entities = ctx.db.game_world_entity(); let radius = active_cell_radius(ctx); let mut out = std::collections::HashSet::new(); - // Perf catalog 1.10 + 1.7: the pet phase and the combat-exit pass each used to run their OWN full - // `entities.iter()` scan per sense tick — one for `owner_guid != 0`, one for the IN_COMBAT bit. - // This scan is already mandatory (it locates the players the active-cell set is built from) and - // already visits every row, so both guid lists ride along for the cost of two bit tests: no new - // table, no lifecycle hooks, and no index maintenance on the hottest write path in the tick (the - // trade the pet pass's own doc rightly rejected). Collected in table order, so both passes visit the - // same candidates in the same order as their old dedicated scans. let mut pets: Vec = Vec::new(); let mut in_combat: Vec = Vec::new(); - let players: Vec = entities - .iter() - .filter(|e| { + + // Pet behavior consumes its candidates only on sense firings. The global combat-drop and regen + // passes consume IN_COMBAT candidates on global sense firings. Keep their shared table-order scan + // on that cadence; non-sense movement firings no longer read the world to build unused lists. + if sense { + let global = scope.runs_global_passes(); + for e in entities.iter() { if e.owner_guid != 0 { pets.push(e.guid); } - if e.unit_flags & lyracore_shared::constants::unit_flags::IN_COMBAT != 0 { + if global + && e.unit_flags & lyracore_shared::constants::unit_flags::IN_COMBAT != 0 + { in_combat.push(e.guid); } - if active_object_enters_scope( - e.is_player(), - scope.covers(e.instance_id), - crate::creatures::active_object(ctx, e.guid), - ) { - out.insert(e.guid); - } - e.is_player() && scope.covers(e.instance_id) - }) + } + } + + let players: Vec = entities + .by_entry() + .filter(&0u32) + .filter(|e| e.is_player() && scope.covers(e.instance_id)) .collect(); + for state in ctx + .db + .game_creature_ai_state() + .by_active_object() + .filter(&true) + { + let Some(e) = entities.guid().find(state.creature_guid) else { + continue; + }; + if active_object_enters_scope(e.is_player(), scope.covers(e.instance_id), true) { + out.insert(e.guid); + } + } for p in players { for c in crate::helpers::entities_near(ctx, p.map_id, p.instance_id, p.x, p.y, radius) { // `cell_is_active` is a belt-and-suspenders re-check of the SAME predicate `entities_near`'s @@ -389,10 +403,7 @@ fn active_object_enters_scope(is_player: bool, partition_covered: bool, active: !is_player && partition_covered && active } -/// Everything ONE pass over `game_world_entity` yields for a firing of `tick_creatures` — the -/// active-cell creature set plus the two small candidate lists that used to cost a dedicated full scan -/// each (perf catalog 1.7 / 1.10). Every field is derived from the SAME row visit, so adding a -/// consumer costs a bit test, not a scan. +/// One firing's active-cell creature set plus the sense-cadence pet and in-combat candidate lists. #[derive(Default)] pub(crate) struct TickSweep { /// Creatures within `active_cell_radius` of at least one covered player (work-item 230). diff --git a/module/src/world.rs b/module/src/world.rs index 313e85f00..d378df2cb 100644 --- a/module/src/world.rs +++ b/module/src/world.rs @@ -108,7 +108,10 @@ pub(crate) fn clear_relay_world_states_for_instance(ctx: &ReducerContext, instan // `entity_by_owner` is the auth prologue of ~77 player reducer call sites; without this it was a // full table scan per transaction (perf catalog 1.2). `owner_identity` never changes for a live // row, so maintenance is insert/delete-only. - index(accessor = by_owner, btree(columns = [owner_identity])) + index(accessor = by_owner, btree(columns = [owner_identity])), + // Character rows have protocol entry 0. The Creature cycle uses this as a sparse seed, then keeps + // the authoritative PLAYER-bit check so additional type-mask bits remain valid. + index(accessor = by_entry, btree(columns = [entry])) )] pub struct WorldEntity { #[primary_key]