mirror of
https://github.com/sigp/lighthouse.git
synced 2026-03-09 03:31:45 +00:00
Move benching_utils structs into types
This commit is contained in:
@@ -11,7 +11,6 @@ harness = false
|
||||
[dev-dependencies]
|
||||
criterion = "0.2"
|
||||
env_logger = "0.6.0"
|
||||
benching_utils = { path = "./benching_utils" }
|
||||
|
||||
[dependencies]
|
||||
bls = { path = "../utils/bls" }
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use benching_utils::{BeaconBlockBencher, BeaconStateBencher};
|
||||
use criterion::Criterion;
|
||||
use criterion::{black_box, Benchmark};
|
||||
use ssz::TreeHash;
|
||||
@@ -10,6 +9,7 @@ use state_processing::{
|
||||
verify_block_signature,
|
||||
},
|
||||
};
|
||||
use types::test_utils::{TestingBeaconBlockBuilder, TestingBeaconStateBuilder};
|
||||
use types::*;
|
||||
|
||||
/// Run the benchmarking suite on a foundation spec with 16,384 validators.
|
||||
@@ -82,7 +82,7 @@ pub fn block_processing_16k_validators(c: &mut Criterion) {
|
||||
}
|
||||
|
||||
fn build_state(validator_count: usize, spec: &ChainSpec) -> (BeaconState, Vec<Keypair>) {
|
||||
let mut builder = BeaconStateBencher::new(validator_count, &spec);
|
||||
let mut builder = TestingBeaconStateBuilder::new(validator_count, &spec);
|
||||
|
||||
// Set the state to be just before an epoch transition.
|
||||
let target_slot = (spec.genesis_epoch + 4).end_slot(spec.slots_per_epoch);
|
||||
@@ -95,7 +95,7 @@ fn build_state(validator_count: usize, spec: &ChainSpec) -> (BeaconState, Vec<Ke
|
||||
}
|
||||
|
||||
fn build_block(state: &mut BeaconState, keypairs: &[Keypair], spec: &ChainSpec) -> BeaconBlock {
|
||||
let mut builder = BeaconBlockBencher::new(spec);
|
||||
let mut builder = TestingBeaconBlockBuilder::new(spec);
|
||||
|
||||
builder.set_slot(state.slot);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use benching_utils::BeaconStateBencher;
|
||||
use criterion::Criterion;
|
||||
use criterion::{black_box, Benchmark};
|
||||
use ssz::TreeHash;
|
||||
@@ -11,6 +10,7 @@ use state_processing::{
|
||||
update_latest_slashed_balances,
|
||||
},
|
||||
};
|
||||
use types::test_utils::TestingBeaconStateBuilder;
|
||||
use types::{validator_registry::get_active_validator_indices, *};
|
||||
|
||||
pub const BENCHING_SAMPLE_SIZE: usize = 10;
|
||||
@@ -22,7 +22,7 @@ pub fn epoch_processing_16k_validators(c: &mut Criterion) {
|
||||
|
||||
let validator_count = 300_032;
|
||||
|
||||
let mut builder = BeaconStateBencher::new(validator_count, &spec);
|
||||
let mut builder = TestingBeaconStateBuilder::new(validator_count, &spec);
|
||||
|
||||
// Set the state to be just before an epoch transition.
|
||||
let target_slot = (spec.genesis_epoch + 4).end_slot(spec.slots_per_epoch);
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
[package]
|
||||
name = "benching_utils"
|
||||
version = "0.1.0"
|
||||
authors = ["Paul Hauner <paul@paulhauner.com>"]
|
||||
edition = "2018"
|
||||
|
||||
[dependencies]
|
||||
bls = { path = "../../utils/bls" }
|
||||
hashing = { path = "../../utils/hashing" }
|
||||
int_to_bytes = { path = "../../utils/int_to_bytes" }
|
||||
integer-sqrt = "0.1"
|
||||
log = "0.4"
|
||||
merkle_proof = { path = "../../utils/merkle_proof" }
|
||||
ssz = { path = "../../utils/ssz" }
|
||||
ssz_derive = { path = "../../utils/ssz_derive" }
|
||||
types = { path = "../../types" }
|
||||
rayon = "1.0"
|
||||
@@ -1,251 +0,0 @@
|
||||
use rayon::prelude::*;
|
||||
use ssz::{SignedRoot, TreeHash};
|
||||
use types::{
|
||||
attester_slashing::AttesterSlashingBuilder,
|
||||
proposer_slashing::ProposerSlashingBuilder,
|
||||
test_utils::{
|
||||
TestingAttestationBuilder, TestingDepositBuilder, TestingTransferBuilder,
|
||||
TestingVoluntaryExitBuilder,
|
||||
},
|
||||
*,
|
||||
};
|
||||
|
||||
pub struct BeaconBlockBencher {
|
||||
block: BeaconBlock,
|
||||
}
|
||||
|
||||
impl BeaconBlockBencher {
|
||||
pub fn new(spec: &ChainSpec) -> Self {
|
||||
Self {
|
||||
block: BeaconBlock::genesis(spec.zero_hash, spec),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_slot(&mut self, slot: Slot) {
|
||||
self.block.slot = slot;
|
||||
}
|
||||
|
||||
/// Signs the block.
|
||||
pub fn sign(&mut self, sk: &SecretKey, fork: &Fork, spec: &ChainSpec) {
|
||||
let proposal = self.block.proposal(spec);
|
||||
let message = proposal.signed_root();
|
||||
let epoch = self.block.slot.epoch(spec.slots_per_epoch);
|
||||
let domain = spec.get_domain(epoch, Domain::Proposal, fork);
|
||||
self.block.signature = Signature::new(&message, domain, sk);
|
||||
}
|
||||
|
||||
/// Sets the randao to be a signature across the blocks epoch.
|
||||
pub fn set_randao_reveal(&mut self, sk: &SecretKey, fork: &Fork, spec: &ChainSpec) {
|
||||
let epoch = self.block.slot.epoch(spec.slots_per_epoch);
|
||||
let message = epoch.hash_tree_root();
|
||||
let domain = spec.get_domain(epoch, Domain::Randao, fork);
|
||||
self.block.randao_reveal = Signature::new(&message, domain, sk);
|
||||
}
|
||||
|
||||
/// Inserts a signed, valid `ProposerSlashing` for the validator.
|
||||
pub fn insert_proposer_slashing(
|
||||
&mut self,
|
||||
validator_index: u64,
|
||||
secret_key: &SecretKey,
|
||||
fork: &Fork,
|
||||
spec: &ChainSpec,
|
||||
) {
|
||||
let proposer_slashing = build_proposer_slashing(validator_index, secret_key, fork, spec);
|
||||
self.block.body.proposer_slashings.push(proposer_slashing);
|
||||
}
|
||||
|
||||
/// Inserts a signed, valid `AttesterSlashing` for each validator index in `validator_indices`.
|
||||
pub fn insert_attester_slashing(
|
||||
&mut self,
|
||||
validator_indices: &[u64],
|
||||
secret_keys: &[&SecretKey],
|
||||
fork: &Fork,
|
||||
spec: &ChainSpec,
|
||||
) {
|
||||
let attester_slashing =
|
||||
build_double_vote_attester_slashing(validator_indices, secret_keys, fork, spec);
|
||||
self.block.body.attester_slashings.push(attester_slashing);
|
||||
}
|
||||
|
||||
/// Fills the block with as many attestations as possible.
|
||||
///
|
||||
/// Note: this will not perform well when `jepoch_committees_count % slots_per_epoch != 0`
|
||||
pub fn fill_with_attestations(
|
||||
&mut self,
|
||||
state: &BeaconState,
|
||||
secret_keys: &[&SecretKey],
|
||||
spec: &ChainSpec,
|
||||
) -> Result<(), BeaconStateError> {
|
||||
let mut slot = self.block.slot - spec.min_attestation_inclusion_delay;
|
||||
let mut attestations_added = 0;
|
||||
|
||||
// Stores the following (in order):
|
||||
//
|
||||
// - The slot of the committee.
|
||||
// - A list of all validators in the committee.
|
||||
// - A list of all validators in the committee that should sign the attestation.
|
||||
// - The shard of the committee.
|
||||
let mut committees: Vec<(Slot, Vec<usize>, Vec<usize>, u64)> = vec![];
|
||||
|
||||
// Loop backwards through slots gathering each committee, until:
|
||||
//
|
||||
// - The slot is too old to be included in a block at this slot.
|
||||
// - The `MAX_ATTESTATIONS`.
|
||||
loop {
|
||||
if state.slot >= slot + spec.slots_per_epoch {
|
||||
break;
|
||||
}
|
||||
|
||||
for (committee, shard) in state.get_crosslink_committees_at_slot(slot, spec)? {
|
||||
if attestations_added >= spec.max_attestations {
|
||||
break;
|
||||
}
|
||||
|
||||
committees.push((slot, committee.clone(), committee.clone(), *shard));
|
||||
|
||||
attestations_added += 1;
|
||||
}
|
||||
|
||||
slot -= 1;
|
||||
}
|
||||
|
||||
// Loop through all the committees, splitting each one in half until we have
|
||||
// `MAX_ATTESTATIONS` committees.
|
||||
loop {
|
||||
if committees.len() >= spec.max_attestations as usize {
|
||||
break;
|
||||
}
|
||||
|
||||
for index in 0..committees.len() {
|
||||
if committees.len() >= spec.max_attestations as usize {
|
||||
break;
|
||||
}
|
||||
|
||||
let (slot, committee, mut signing_validators, shard) = committees[index].clone();
|
||||
|
||||
let new_signing_validators =
|
||||
signing_validators.split_off(signing_validators.len() / 2);
|
||||
|
||||
committees[index] = (slot, committee.clone(), signing_validators, shard);
|
||||
committees.push((slot, committee, new_signing_validators, shard));
|
||||
}
|
||||
}
|
||||
|
||||
let mut attestations: Vec<Attestation> = committees
|
||||
.par_iter()
|
||||
.map(|(slot, committee, signing_validators, shard)| {
|
||||
let mut builder =
|
||||
TestingAttestationBuilder::new(state, committee, *slot, *shard, spec);
|
||||
|
||||
let signing_secret_keys: Vec<&SecretKey> = signing_validators
|
||||
.iter()
|
||||
.map(|validator_index| secret_keys[*validator_index])
|
||||
.collect();
|
||||
builder.sign(signing_validators, &signing_secret_keys, &state.fork, spec);
|
||||
|
||||
builder.build()
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.block.body.attestations.append(&mut attestations);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Insert a `Valid` deposit into the state.
|
||||
pub fn insert_deposit(&mut self, amount: u64, index: u64, spec: &ChainSpec) {
|
||||
let keypair = Keypair::random();
|
||||
|
||||
let mut builder = TestingDepositBuilder::new(amount);
|
||||
builder.set_index(index);
|
||||
builder.sign(&keypair, spec);
|
||||
|
||||
self.block.body.deposits.push(builder.build())
|
||||
}
|
||||
|
||||
/// Insert a `Valid` exit into the state.
|
||||
pub fn insert_exit(
|
||||
&mut self,
|
||||
state: &BeaconState,
|
||||
validator_index: u64,
|
||||
secret_key: &SecretKey,
|
||||
spec: &ChainSpec,
|
||||
) {
|
||||
let mut builder = TestingVoluntaryExitBuilder::new(
|
||||
state.slot.epoch(spec.slots_per_epoch),
|
||||
validator_index,
|
||||
);
|
||||
|
||||
builder.sign(secret_key, &state.fork, spec);
|
||||
|
||||
self.block.body.voluntary_exits.push(builder.build())
|
||||
}
|
||||
|
||||
/// Insert a `Valid` transfer into the state.
|
||||
///
|
||||
/// Note: this will set the validator to be withdrawable by directly modifying the state
|
||||
/// validator registry. This _may_ cause problems historic hashes, etc.
|
||||
pub fn insert_transfer(
|
||||
&mut self,
|
||||
state: &BeaconState,
|
||||
from: u64,
|
||||
to: u64,
|
||||
amount: u64,
|
||||
keypair: Keypair,
|
||||
spec: &ChainSpec,
|
||||
) {
|
||||
let mut builder = TestingTransferBuilder::new(from, to, amount, state.slot);
|
||||
builder.sign(keypair, &state.fork, spec);
|
||||
|
||||
self.block.body.transfers.push(builder.build())
|
||||
}
|
||||
|
||||
/// Signs and returns the block, consuming the builder.
|
||||
pub fn build(mut self, sk: &SecretKey, fork: &Fork, spec: &ChainSpec) -> BeaconBlock {
|
||||
self.sign(sk, fork, spec);
|
||||
self.block
|
||||
}
|
||||
|
||||
/// Returns the block, consuming the builder.
|
||||
pub fn build_without_signing(self) -> BeaconBlock {
|
||||
self.block
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an `ProposerSlashing` for some `validator_index`.
|
||||
///
|
||||
/// Signs the message using a `BeaconChainHarness`.
|
||||
fn build_proposer_slashing(
|
||||
validator_index: u64,
|
||||
secret_key: &SecretKey,
|
||||
fork: &Fork,
|
||||
spec: &ChainSpec,
|
||||
) -> ProposerSlashing {
|
||||
let signer = |_validator_index: u64, message: &[u8], epoch: Epoch, domain: Domain| {
|
||||
let domain = spec.get_domain(epoch, domain, fork);
|
||||
Signature::new(message, domain, secret_key)
|
||||
};
|
||||
|
||||
ProposerSlashingBuilder::double_vote(validator_index, signer, spec)
|
||||
}
|
||||
|
||||
/// Builds an `AttesterSlashing` for some `validator_indices`.
|
||||
///
|
||||
/// Signs the message using a `BeaconChainHarness`.
|
||||
fn build_double_vote_attester_slashing(
|
||||
validator_indices: &[u64],
|
||||
secret_keys: &[&SecretKey],
|
||||
fork: &Fork,
|
||||
spec: &ChainSpec,
|
||||
) -> AttesterSlashing {
|
||||
let signer = |validator_index: u64, message: &[u8], epoch: Epoch, domain: Domain| {
|
||||
let key_index = validator_indices
|
||||
.iter()
|
||||
.position(|&i| i == validator_index)
|
||||
.expect("Unable to find attester slashing key");
|
||||
let domain = spec.get_domain(epoch, domain, fork);
|
||||
Signature::new(message, domain, secret_keys[key_index])
|
||||
};
|
||||
|
||||
AttesterSlashingBuilder::double_vote(validator_indices, signer)
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
use bls::get_withdrawal_credentials;
|
||||
use int_to_bytes::int_to_bytes48;
|
||||
use rayon::prelude::*;
|
||||
use types::beacon_state::BeaconStateBuilder;
|
||||
use types::*;
|
||||
|
||||
pub struct BeaconStateBencher {
|
||||
state: BeaconState,
|
||||
keypairs: Vec<Keypair>,
|
||||
}
|
||||
|
||||
impl BeaconStateBencher {
|
||||
pub fn new(validator_count: usize, spec: &ChainSpec) -> Self {
|
||||
let keypairs: Vec<Keypair> = (0..validator_count)
|
||||
.collect::<Vec<usize>>()
|
||||
.par_iter()
|
||||
.map(|&i| {
|
||||
let secret = int_to_bytes48(i as u64 + 1);
|
||||
let sk = SecretKey::from_bytes(&secret).unwrap();
|
||||
let pk = PublicKey::from_secret_key(&sk);
|
||||
Keypair { sk, pk }
|
||||
})
|
||||
.collect();
|
||||
|
||||
let validators = keypairs
|
||||
.iter()
|
||||
.map(|keypair| {
|
||||
let withdrawal_credentials = Hash256::from_slice(&get_withdrawal_credentials(
|
||||
&keypair.pk,
|
||||
spec.bls_withdrawal_prefix_byte,
|
||||
));
|
||||
|
||||
Validator {
|
||||
pubkey: keypair.pk.clone(),
|
||||
withdrawal_credentials,
|
||||
activation_epoch: spec.far_future_epoch,
|
||||
exit_epoch: spec.far_future_epoch,
|
||||
withdrawable_epoch: spec.far_future_epoch,
|
||||
initiated_exit: false,
|
||||
slashed: false,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut state_builder = BeaconStateBuilder::new(
|
||||
0,
|
||||
Eth1Data {
|
||||
deposit_root: Hash256::zero(),
|
||||
block_hash: Hash256::zero(),
|
||||
},
|
||||
spec,
|
||||
);
|
||||
|
||||
let balances = vec![32_000_000_000; validator_count];
|
||||
|
||||
state_builder.import_existing_validators(
|
||||
validators,
|
||||
balances,
|
||||
validator_count as u64,
|
||||
spec,
|
||||
);
|
||||
|
||||
Self {
|
||||
state: state_builder.build(spec).unwrap(),
|
||||
keypairs,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn build(self) -> (BeaconState, Vec<Keypair>) {
|
||||
(self.state, self.keypairs)
|
||||
}
|
||||
|
||||
pub fn build_caches(&mut self, spec: &ChainSpec) -> Result<(), BeaconStateError> {
|
||||
let state = &mut self.state;
|
||||
|
||||
state.build_epoch_cache(RelativeEpoch::Previous, &spec)?;
|
||||
state.build_epoch_cache(RelativeEpoch::Current, &spec)?;
|
||||
state.build_epoch_cache(RelativeEpoch::Next, &spec)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets the `BeaconState` to be in a slot, calling `teleport_to_epoch` to update the epoch.
|
||||
pub fn teleport_to_slot(&mut self, slot: Slot, spec: &ChainSpec) {
|
||||
self.teleport_to_epoch(slot.epoch(spec.slots_per_epoch), spec);
|
||||
self.state.slot = slot;
|
||||
}
|
||||
|
||||
/// Sets the `BeaconState` to be in the first slot of the given epoch.
|
||||
///
|
||||
/// Sets all justification/finalization parameters to be be as "perfect" as possible (i.e.,
|
||||
/// highest justified and finalized slots, full justification bitfield, etc).
|
||||
fn teleport_to_epoch(&mut self, epoch: Epoch, spec: &ChainSpec) {
|
||||
let state = &mut self.state;
|
||||
|
||||
let slot = epoch.start_slot(spec.slots_per_epoch);
|
||||
|
||||
state.slot = slot;
|
||||
|
||||
state.previous_shuffling_epoch = epoch - 1;
|
||||
state.current_shuffling_epoch = epoch;
|
||||
|
||||
state.previous_shuffling_seed = Hash256::from_low_u64_le(0);
|
||||
state.current_shuffling_seed = Hash256::from_low_u64_le(1);
|
||||
|
||||
state.previous_justified_epoch = epoch - 3;
|
||||
state.justified_epoch = epoch - 2;
|
||||
state.justification_bitfield = u64::max_value();
|
||||
|
||||
state.finalized_epoch = epoch - 3;
|
||||
state.validator_registry_update_epoch = epoch - 3;
|
||||
}
|
||||
|
||||
/// Creates a full set of attestations for the `BeaconState`. Each attestation has full
|
||||
/// participation from its committee and references the expected beacon_block hashes.
|
||||
///
|
||||
/// These attestations should be fully conducive to justification and finalization.
|
||||
pub fn insert_attestations(&mut self, spec: &ChainSpec) {
|
||||
let state = &mut self.state;
|
||||
|
||||
state
|
||||
.build_epoch_cache(RelativeEpoch::Previous, spec)
|
||||
.unwrap();
|
||||
state
|
||||
.build_epoch_cache(RelativeEpoch::Current, spec)
|
||||
.unwrap();
|
||||
|
||||
let current_epoch = state.current_epoch(spec);
|
||||
let previous_epoch = state.previous_epoch(spec);
|
||||
|
||||
let first_slot = previous_epoch.start_slot(spec.slots_per_epoch).as_u64();
|
||||
let last_slot = current_epoch.end_slot(spec.slots_per_epoch).as_u64()
|
||||
- spec.min_attestation_inclusion_delay;
|
||||
let last_slot = std::cmp::min(state.slot.as_u64(), last_slot);
|
||||
|
||||
for slot in first_slot..last_slot + 1 {
|
||||
let slot = Slot::from(slot);
|
||||
|
||||
let committees = state
|
||||
.get_crosslink_committees_at_slot(slot, spec)
|
||||
.unwrap()
|
||||
.clone();
|
||||
|
||||
for (committee, shard) in committees {
|
||||
state
|
||||
.latest_attestations
|
||||
.push(committee_to_pending_attestation(
|
||||
state, &committee, shard, slot, spec,
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn committee_to_pending_attestation(
|
||||
state: &BeaconState,
|
||||
committee: &[usize],
|
||||
shard: u64,
|
||||
slot: Slot,
|
||||
spec: &ChainSpec,
|
||||
) -> PendingAttestation {
|
||||
let current_epoch = state.current_epoch(spec);
|
||||
let previous_epoch = state.previous_epoch(spec);
|
||||
|
||||
let mut aggregation_bitfield = Bitfield::new();
|
||||
let mut custody_bitfield = Bitfield::new();
|
||||
|
||||
for (i, _) in committee.iter().enumerate() {
|
||||
aggregation_bitfield.set(i, true);
|
||||
custody_bitfield.set(i, true);
|
||||
}
|
||||
|
||||
let is_previous_epoch =
|
||||
state.slot.epoch(spec.slots_per_epoch) != slot.epoch(spec.slots_per_epoch);
|
||||
|
||||
let justified_epoch = if is_previous_epoch {
|
||||
state.previous_justified_epoch
|
||||
} else {
|
||||
state.justified_epoch
|
||||
};
|
||||
|
||||
let epoch_boundary_root = if is_previous_epoch {
|
||||
*state
|
||||
.get_block_root(previous_epoch.start_slot(spec.slots_per_epoch), spec)
|
||||
.unwrap()
|
||||
} else {
|
||||
*state
|
||||
.get_block_root(current_epoch.start_slot(spec.slots_per_epoch), spec)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let justified_block_root = *state
|
||||
.get_block_root(justified_epoch.start_slot(spec.slots_per_epoch), spec)
|
||||
.unwrap();
|
||||
|
||||
PendingAttestation {
|
||||
aggregation_bitfield,
|
||||
data: AttestationData {
|
||||
slot,
|
||||
shard,
|
||||
beacon_block_root: *state.get_block_root(slot, spec).unwrap(),
|
||||
epoch_boundary_root,
|
||||
crosslink_data_root: Hash256::zero(),
|
||||
latest_crosslink: Crosslink {
|
||||
epoch: slot.epoch(spec.slots_per_epoch),
|
||||
crosslink_data_root: Hash256::zero(),
|
||||
},
|
||||
justified_epoch,
|
||||
justified_block_root,
|
||||
},
|
||||
custody_bitfield,
|
||||
inclusion_slot: slot + spec.min_attestation_inclusion_delay,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
mod beacon_block_bencher;
|
||||
mod beacon_state_bencher;
|
||||
|
||||
pub use beacon_block_bencher::BeaconBlockBencher;
|
||||
pub use beacon_state_bencher::BeaconStateBencher;
|
||||
@@ -1,7 +1,7 @@
|
||||
#![cfg(test)]
|
||||
use crate::per_epoch_processing;
|
||||
use benching_utils::BeaconStateBencher;
|
||||
use env_logger::{Builder, Env};
|
||||
use types::test_utils::TestingBeaconStateBuilder;
|
||||
use types::*;
|
||||
|
||||
#[test]
|
||||
@@ -10,7 +10,7 @@ fn runs_without_error() {
|
||||
|
||||
let spec = ChainSpec::few_validators();
|
||||
|
||||
let mut builder = BeaconStateBencher::new(8, &spec);
|
||||
let mut builder = TestingBeaconStateBuilder::new(8, &spec);
|
||||
|
||||
let target_slot = (spec.genesis_epoch + 4).end_slot(spec.slots_per_epoch);
|
||||
builder.teleport_to_slot(target_slot, &spec);
|
||||
|
||||
Reference in New Issue
Block a user