use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
use crate::ecs::{Components, Entity};
use crate::inventory::Fill;
use crate::raw::read_at;
use crate::{Load, Next, Off};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub struct Kinds(u8);
impl Kinds {
pub const CORPSES: Self = Self(1);
pub const CHESTS: Self = Self(2);
pub const DESTROYED: Self = Self(4);
pub const CONTAINERS: Self = Self(1 | 2);
#[must_use]
pub const fn contains(self, other: Self) -> bool {
self.0 & other.0 == other.0
}
#[must_use]
pub const fn with(self, other: Self) -> Self {
Self(self.0 | other.0)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Roll {
pub kind: Kinds,
pub entity: Option<Entity>,
pub node: u32,
pub labels: u32,
pub boss: bool,
pub locked: bool,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Source {
pub kinds: Kinds,
pub bosses_only: bool,
pub locked_only: bool,
pub labels: Vec<u32>,
pub nodes: Vec<u32>,
}
impl Source {
#[must_use]
pub const fn kinds(kinds: Kinds) -> Self {
Self {
kinds,
bosses_only: false,
locked_only: false,
labels: Vec::new(),
nodes: Vec::new(),
}
}
#[must_use]
pub const fn bosses() -> Self {
let mut source = Self::kinds(Kinds::CORPSES);
source.bosses_only = true;
source
}
#[must_use]
pub const fn locked_chests() -> Self {
let mut source = Self::kinds(Kinds::CHESTS);
source.locked_only = true;
source
}
#[must_use]
pub fn with_labels(mut self, labels: &[u32]) -> Self {
self.labels.extend_from_slice(labels);
self
}
#[must_use]
pub fn with_nodes(mut self, nodes: &[u32]) -> Self {
self.nodes.extend_from_slice(nodes);
self
}
#[must_use]
pub fn matches(&self, roll: &Roll) -> bool {
self.kinds.contains(roll.kind)
&& (!self.bosses_only || roll.boss)
&& (!self.locked_only || roll.locked)
&& (self.labels.is_empty() || self.labels.contains(&roll.labels))
&& (self.nodes.is_empty() || self.nodes.contains(&roll.node))
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Drop {
pub items: Vec<u32>,
pub chance: f64,
pub count: [u16; 2],
pub meter: Option<MeterRoll>,
pub from: Source,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct MeterRoll {
pub min: u16,
pub max: u16,
pub of: u16,
}
impl Drop {
#[must_use]
pub fn new(item: u32, chance: f64, from: Source) -> Self {
Self::one_of(&[item], chance, from)
}
#[must_use]
pub fn one_of(items: &[u32], chance: f64, from: Source) -> Self {
Self {
items: items.to_vec(),
chance,
count: [1, 1],
meter: None,
from,
}
}
#[must_use]
pub const fn count(mut self, min: u16, max: u16) -> Self {
self.count = [min, max];
self
}
#[must_use]
pub const fn meter(mut self, min: u16, max: u16, of: u16) -> Self {
self.meter = Some(MeterRoll { min, max, of });
self
}
fn check(&self) -> Result<(), Off> {
if self.items.is_empty() || self.items.contains(&0) {
return Err(Off::new(format!("loot drop {:?}: no item", self.items)));
}
if !(0.0..=1.0).contains(&self.chance) {
return Err(Off::new(format!(
"loot drop {:?}: chance {} is not between 0 and 1",
self.items, self.chance
)));
}
let [min, max] = self.count;
if min == 0 || min > max {
return Err(Off::new(format!(
"loot drop {:?}: count {min}..{max} is empty",
self.items
)));
}
if let Some(MeterRoll { min, max, of }) = self.meter
&& (min > max || max > of || of == 0)
{
return Err(Off::new(format!(
"loot drop {:?}: meter {min}..{max} of {of} does not fit",
self.items
)));
}
Ok(())
}
fn draw(&self, dice: &Dice) -> Option<(u32, u16, Option<Fill>)> {
if dice.unit() >= self.chance {
return None;
}
let pick = |n: u64| usize::try_from(dice.next() % n.max(1)).unwrap_or(0);
let between = |min: u16, max: u16| {
min + u16::try_from(pick(u64::from(max.saturating_sub(min)) + 1)).unwrap_or(0)
};
let item = *self.items.get(pick(self.items.len() as u64))?;
let [min, max] = self.count;
let fill = self.meter.map(|m| Fill::of(between(m.min, m.max), m.of));
Some((item, between(min, max), fill))
}
}
#[derive(Debug)]
struct Dice(AtomicU64);
impl Dice {
const GAMMA: u64 = 0x9e37_79b9_7f4a_7c15;
fn next(&self) -> u64 {
let mut z = self
.0
.fetch_add(Self::GAMMA, Ordering::Relaxed)
.wrapping_add(Self::GAMMA);
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
z ^ (z >> 31)
}
#[expect(clippy::cast_precision_loss, reason = "53 bits fit an f64 exactly")]
fn unit(&self) -> f64 {
(self.next() >> 11) as f64 / (1u64 << 53) as f64
}
}
type RollFn = unsafe extern "system" fn(
res: *mut u8,
txn: *mut u8,
generation: *mut u8,
labels: *mut u8,
request: *const u8,
) -> *mut u8;
type GiveFn = unsafe extern "system" fn(
res: *mut [u64; 2],
txn: *mut u8,
inventory: u32,
hint: u64,
item: u32,
count: i32,
level: *const [u8; 16],
rarity: *const [u8; 16],
extra: *const [u8; 16],
) -> *mut [u64; 2];
crate::detour! {
static ROLL: unsafe extern "system" fn(
res: *mut u8,
txn: *mut u8,
generation: *mut u8,
labels: *mut u8,
request: *const u8,
) -> *mut u8 = after_roll;
}
crate::detour! {
static ROLL_ONE: unsafe extern "system" fn(
res: *mut u8,
out: *mut u8,
generation: *mut u8,
labels: *mut u8,
request: *const u8,
) -> *mut u8 = after_roll_one;
}
#[derive(Debug)]
struct Engine {
give: GiveFn,
components: Components,
txn_context: usize,
txn_entity: usize,
request_node: usize,
request_labels: usize,
destroy_on_loot: u32,
boss: u32,
lock: u32,
lock_item: usize,
}
static ENGINE: OnceLock<Engine> = OnceLock::new();
static DROPS: Mutex<Vec<(u64, Drop)>> = Mutex::new(Vec::new());
static ONE_LINKED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
static WATCHERS: Mutex<Vec<Arc<Mutex<Vec<Roll>>>>> = Mutex::new(Vec::new());
static DICE: OnceLock<Dice> = OnceLock::new();
fn dice() -> &'static Dice {
DICE.get_or_init(|| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos());
#[expect(clippy::cast_possible_truncation, reason = "the low bits seed")]
Dice(AtomicU64::new(
now as u64 ^ u64::from(std::process::id()).rotate_left(32),
))
})
}
unsafe fn function<F: Copy>(address: u64) -> F {
let address = usize::try_from(address).unwrap_or(0);
unsafe { core::mem::transmute_copy(&address) }
}
impl Engine {
fn resolve(load: &mut Load<'_>) -> Result<Self, Off> {
let world = load.world()?;
let ty = |name: &str| {
world
.component_type(load, name)
.map(crate::world::ComponentType::hash)
};
let size = |name: &str| {
load.constant(name)
.map(|v| usize::try_from(v).unwrap_or(usize::MAX))
};
let give = load.symbol("inventory.give")?;
Ok(Self {
give: unsafe { function(give) },
components: Components::resolve(load)?,
txn_context: size("loot.txn_context")?,
txn_entity: size("loot.txn_entity")?,
request_node: size("loot.request_node")?,
request_labels: size("loot.request_labels")?,
destroy_on_loot: ty("keen::ecs::DestroyOnLoot")?,
boss: ty("keen::ecs::Boss")?,
lock: ty("keen::ecs::InteractionLock")?,
lock_item: load
.field_offset("keen::ecs::InteractionLock", "requiredItem")
.and_then(|v| usize::try_from(v).map_err(|_| Off::new("offset")))?,
})
}
unsafe fn roll(&self, txn: *mut u8, request: *const u8) -> (Roll, *mut u64) {
let ctx: *mut u64 = unsafe { read_at(txn, self.txn_context) };
let entity = Entity(unsafe { read_at(txn, self.txn_entity) });
let node: u32 = unsafe { read_at(request, self.request_node) };
let labels: u32 = unsafe { read_at(request, self.request_labels) };
let has = |hash| unsafe { self.components.component(ctx, entity, hash) };
let corpse = has(self.destroy_on_loot).is_some() || node == 0;
let locked = has(self.lock).is_some_and(|data| {
unsafe { read_at::<u32>(data, self.lock_item) != 0 }
});
let roll = Roll {
kind: if corpse {
Kinds::CORPSES
} else {
Kinds::CHESTS
},
entity: Some(entity),
node,
labels,
boss: corpse && has(self.boss).is_some(),
locked: !corpse && locked,
};
(roll, ctx)
}
}
fn after_roll(
next: Next<RollFn>,
res: *mut u8,
txn: *mut u8,
generation: *mut u8,
labels: *mut u8,
request: *const u8,
) -> *mut u8 {
let out = unsafe { next.get()(res, txn, generation, labels, request) };
crate::host::guard(
"panic in a loot roll",
|| (),
|| add_drops(res, txn, request),
);
out
}
fn add_drops(res: *mut u8, txn: *mut u8, request: *const u8) {
let Some(engine) = ENGINE.get() else {
return;
};
if res.is_null() || txn.is_null() || request.is_null() || unsafe { res.read() } != 0 {
return;
}
let (roll, _) = unsafe { engine.roll(txn, request) };
for watcher in WATCHERS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.iter()
{
watcher
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(roll);
}
let Some(container) = roll.entity else {
return;
};
let drops = DROPS.lock().unwrap_or_else(PoisonError::into_inner);
let absent = [0u8; 16];
for drop in drops
.iter()
.map(|(_, d)| d)
.filter(|d| d.from.matches(&roll))
{
let Some((item, count, fill)) = drop.draw(dice()) else {
continue;
};
let mut extra = absent;
if let Some(fill) = fill {
extra[..4].copy_from_slice(&fill.fraction().to_le_bytes());
extra[4] = 1;
}
let mut added = [0u64; 2];
unsafe {
(engine.give)(
&raw mut added,
txn,
container.0,
0,
item,
i32::from(count),
&raw const absent,
&raw const absent,
&raw const extra,
)
};
crate::debug!(
"loot: {count} of item {item} into {:?} ({:?}), status {}",
roll.entity,
roll.kind,
added[0] & 0xff
);
}
}
const ONE_ITEM: usize = 0;
const ONE_COUNT: usize = 4;
const ONE_SIZE: usize = 16;
fn after_roll_one(
next: Next<RollFn>,
res: *mut u8,
out: *mut u8,
generation: *mut u8,
labels: *mut u8,
request: *const u8,
) -> *mut u8 {
let ret = unsafe { next.get()(res, out, generation, labels, request) };
crate::host::guard("panic in a loot roll", || (), || add_one(res, out));
ret
}
fn add_one(res: *mut u8, out: *mut u8) {
if ENGINE.get().is_none() || res.is_null() || out.is_null() {
return;
}
let roll = Roll {
kind: Kinds::DESTROYED,
entity: None,
node: 0,
labels: 0,
boss: false,
locked: false,
};
let drops = DROPS.lock().unwrap_or_else(PoisonError::into_inner);
let Some((item, count, _)) = drops
.iter()
.map(|(_, d)| d)
.filter(|d| d.from.matches(&roll))
.find_map(|d| d.draw(dice()))
else {
return;
};
let mut stack = [0u8; ONE_SIZE];
stack[ONE_ITEM..ONE_ITEM + 4].copy_from_slice(&item.to_le_bytes());
stack[ONE_COUNT..ONE_COUNT + 2].copy_from_slice(&count.to_le_bytes());
unsafe { out.cast::<[u8; ONE_SIZE]>().write_unaligned(stack) };
unsafe { res.write(0) };
crate::debug!("loot: {count} of item {item} from a single roll");
}
fn link_roll(load: &mut Load<'_>) -> Result<(), Off> {
if ENGINE.get().is_none() {
let engine = Engine::resolve(load)?;
let roll = load.symbol("loot.roll")?;
unsafe { load.hook(&ROLL, roll) }?;
let _first = ENGINE.set(engine);
}
Ok(())
}
pub(crate) fn watch_rolls(load: &mut Load<'_>) -> Result<Arc<Mutex<Vec<Roll>>>, Off> {
link_roll(load)?;
let queue = Arc::new(Mutex::new(Vec::new()));
WATCHERS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push(Arc::clone(&queue));
Ok(queue)
}
impl Load<'_> {
pub fn add_loot(&mut self, drop: Drop) -> Result<(), Off> {
self.add_loot_in(0, drop)
}
pub(crate) fn add_loot_in(&mut self, group: u64, drop: Drop) -> Result<(), Off> {
drop.check()?;
link_roll(self)?;
if drop.from.kinds.contains(Kinds::DESTROYED) && !ONE_LINKED.load(Ordering::Acquire) {
let roll_one = self.symbol("loot.roll_one")?;
unsafe { self.hook(&ROLL_ONE, roll_one) }?;
ONE_LINKED.store(true, Ordering::Release);
}
DROPS
.lock()
.unwrap_or_else(PoisonError::into_inner)
.push((group, drop));
Ok(())
}
}
pub fn replace_drops(drops: Vec<Drop>) -> Result<(), Off> {
replace_group(0, drops).map_err(Off::new)
}
pub(crate) fn replace_group(group: u64, drops: Vec<Drop>) -> Result<(), String> {
for drop in &drops {
drop.check().map_err(|off| off.to_string())?;
if ENGINE.get().is_none() {
return Err("no loot was linked at start; it takes a restart".to_owned());
}
if drop.from.kinds.contains(Kinds::DESTROYED) && !ONE_LINKED.load(Ordering::Acquire) {
return Err(
"single-item rolls were not linked at start; they take a restart".to_owned(),
);
}
}
let mut all = DROPS.lock().unwrap_or_else(PoisonError::into_inner);
all.retain(|(g, _)| *g != group);
all.extend(drops.into_iter().map(|drop| (group, drop)));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn roll(kind: Kinds) -> Roll {
Roll {
kind,
entity: Some(Entity(5)),
node: 0,
labels: 0,
boss: false,
locked: false,
}
}
#[test]
fn filters_keep_their_kind_and_markers() {
let corpse = roll(Kinds::CORPSES);
let boss = Roll {
boss: true,
labels: 3,
..corpse
};
let chest = Roll {
node: 9,
..roll(Kinds::CHESTS)
};
let locked = Roll {
locked: true,
..chest
};
assert!(Source::kinds(Kinds::CONTAINERS).matches(&corpse));
assert!(Source::kinds(Kinds::CONTAINERS).matches(&chest));
assert!(!Source::kinds(Kinds::CONTAINERS).matches(&roll(Kinds::DESTROYED)));
assert!(Source::bosses().matches(&boss) && !Source::bosses().matches(&corpse));
assert!(!Source::bosses().matches(&locked));
assert!(Source::locked_chests().matches(&locked));
assert!(!Source::locked_chests().matches(&chest));
assert!(Source::bosses().with_labels(&[3]).matches(&boss));
assert!(!Source::bosses().with_labels(&[4]).matches(&boss));
assert!(
Source::kinds(Kinds::CHESTS)
.with_nodes(&[9])
.matches(&chest)
);
assert!(
!Source::kinds(Kinds::CHESTS)
.with_nodes(&[8])
.matches(&chest)
);
}
#[test]
fn invalid_drops_are_refused() {
let from = Source::bosses();
assert!(Drop::new(7, 0.5, from.clone()).check().is_ok());
assert!(Drop::new(0, 0.5, from.clone()).check().is_err());
assert!(Drop::new(7, 1.5, from.clone()).check().is_err());
assert!(Drop::new(7, f64::NAN, from.clone()).check().is_err());
assert!(Drop::new(7, 0.5, from.clone()).count(0, 1).check().is_err());
assert!(Drop::new(7, 0.5, from.clone()).count(3, 2).check().is_err());
assert!(
Drop::new(7, 0.5, from.clone())
.meter(1, 5, 5)
.check()
.is_ok()
);
assert!(
Drop::new(7, 0.5, from.clone())
.meter(3, 2, 5)
.check()
.is_err()
);
assert!(
Drop::new(7, 0.5, from.clone())
.meter(1, 6, 5)
.check()
.is_err()
);
assert!(Drop::new(7, 0.5, from).meter(0, 0, 0).check().is_err());
}
#[test]
fn meters_roll_every_count_between_their_bounds() {
let dice = Dice(AtomicU64::new(3));
let drop = Drop::new(7, 1.0, Source::bosses()).meter(2, 4, 5);
let mut seen = [0usize; 6];
for _ in 0..30_000 {
let (_, _, fill) = drop.draw(&dice).unwrap();
let charges = (5.0 * fill.unwrap().fraction() + 0.5).floor();
#[expect(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
reason = "0 to 5"
)]
let charges = charges as usize;
seen[charges] += 1;
}
assert_eq!((seen[0], seen[1], seen[5]), (0, 0, 0));
assert!(
seen[2..5].iter().all(|&n| (9_000..11_000).contains(&n)),
"{seen:?}"
);
}
#[test]
fn the_chance_holds_over_many_rolls() {
let dice = Dice(AtomicU64::new(42));
let drop = Drop::new(7, 0.1, Source::bosses()).count(2, 4);
let mut hits = 0;
for _ in 0..100_000 {
if let Some((item, n, fill)) = drop.draw(&dice) {
assert_eq!((item, fill), (7, None));
assert!((2..=4).contains(&n));
hits += 1;
}
}
assert!((9_000..11_000).contains(&hits), "{hits}");
let never = Drop::new(7, 0.0, Source::bosses());
let always = Drop::new(7, 1.0, Source::bosses());
for _ in 0..1000 {
assert_eq!(never.draw(&dice), None);
assert_eq!(always.draw(&dice), Some((7, 1, None)));
}
}
#[test]
fn one_of_spreads_over_its_items() {
let dice = Dice(AtomicU64::new(7));
let drop = Drop::one_of(&[1, 2, 3], 1.0, Source::bosses());
let mut seen = [0usize; 3];
for _ in 0..30_000 {
let (item, _, _) = drop.draw(&dice).unwrap();
seen[item as usize - 1] += 1;
}
assert!(
seen.iter().all(|&n| (9_000..11_000).contains(&n)),
"{seen:?}"
);
assert!(Drop::one_of(&[], 0.5, Source::bosses()).check().is_err());
assert!(
Drop::one_of(&[1, 0], 0.5, Source::bosses())
.check()
.is_err()
);
}
}