use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use crate::ecs::{ComponentType, Components};
use crate::engine_ids as ids;
use crate::fresh::Fresh;
use crate::inventory::Fill;
use crate::locks::Relock;
use crate::raw::{read_at, read_ptr_at, write_at, write_bytes_at};
use crate::{Entity, ItemId};
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, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct LootContainerId(u32);
crate::abi::id_conversions!(LootContainerId(u32));
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Roll {
pub kind: Kinds,
pub entity: Option<Entity>,
pub node: Option<LootContainerId>,
pub labels: Option<LootContainerId>,
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<LootContainerId>,
pub nodes: Vec<LootContainerId>,
}
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: &[LootContainerId]) -> Self {
self.labels.extend_from_slice(labels);
self
}
#[must_use]
pub fn with_nodes(mut self, nodes: &[LootContainerId]) -> 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() || roll.labels.is_some_and(|l| self.labels.contains(&l)))
&& (self.nodes.is_empty() || roll.node.is_some_and(|n| self.nodes.contains(&n)))
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct Drop {
pub items: Vec<ItemId>,
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: ItemId, chance: f64, from: Source) -> Self {
Self::one_of(&[item], chance, from)
}
#[must_use]
pub fn one_of(items: &[ItemId], 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(&ItemId::new(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))?.get();
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: ComponentType,
boss: ComponentType,
lock: ComponentType,
lock_item: usize,
slot: Option<Slot>,
}
static ENGINE: Fresh<Engine> = Fresh::new();
static DROPS: Fresh<Mutex<Vec<(u64, Drop)>>> = Fresh::new();
static ONE_LINKED: Fresh<std::sync::atomic::AtomicBool> = Fresh::new();
static WATCHERS: Fresh<Mutex<Vec<Watcher>>> = Fresh::new();
type Watcher = Arc<Mutex<Vec<Roll>>>;
static DICE: Fresh<Dice> = Fresh::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: crate::FnPtr>(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 ty = |id: &str| ComponentType::resolve(load, id);
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(ids::DESTROY_ON_LOOT)?,
boss: ty(ids::BOSS)?,
lock: ty(ids::INTERACTION_LOCK)?,
lock_item: load.constant(ids::INTERACTION_LOCK_ITEM).and_then(|v| {
usize::try_from(v)
.map_err(|_| Off::new(format!("offset {}", ids::INTERACTION_LOCK_ITEM)))
})?,
slot: Slot::resolve(load)
.inspect_err(|off| crate::debug!("loot from destroyed things off: {off}"))
.ok(),
})
}
unsafe fn roll(&self, txn: *mut u8, request: *const u8) -> (Roll, *mut u64) {
let ctx = unsafe { read_ptr_at::<u64>(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: (node != 0).then_some(LootContainerId(node)),
labels: (labels != 0).then_some(LootContainerId(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",
|| (),
|| unsafe { add_drops(res, txn, request) },
);
out
}
unsafe 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() {
return;
}
if unsafe { read_at::<u8>(res, 0) } != 0 {
return;
}
let (roll, _) = unsafe { engine.roll(txn, request) };
for watcher in WATCHERS.get_or_init(Mutex::default).relock().iter() {
watcher.relock().push(roll);
}
let Some(container) = roll.entity else {
return;
};
let drops = DROPS.get_or_init(Mutex::default).relock();
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 SLOT_MAX: usize = 64;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct Slot {
item: usize,
count: usize,
size: usize,
}
impl Slot {
fn new(item: usize, count: usize, size: usize) -> Option<Self> {
let fits = |at: usize, len: usize| at.checked_add(len).is_some_and(|end| end <= size);
let apart = item.saturating_add(size_of::<u32>()) <= count
|| count.saturating_add(size_of::<u16>()) <= item;
(size <= SLOT_MAX && fits(item, size_of::<u32>()) && fits(count, size_of::<u16>()) && apart)
.then_some(Self { item, count, size })
}
fn resolve(load: &Load<'_>) -> Result<Self, Off> {
let at = |id: &str| {
load.constant(id)
.and_then(|v| usize::try_from(v).map_err(|_| Off::new(format!("offset {id}"))))
};
let size = usize::try_from(load.type_size(ids::LOOT_SLOT)?).unwrap_or(usize::MAX);
Self::new(at(ids::LOOT_SLOT_ITEM)?, at(ids::LOOT_SLOT_COUNT)?, size).ok_or_else(|| {
Off::new(format!(
"{}: not the layout this API writes",
ids::LOOT_SLOT
))
})
}
fn fill(self, item: u32, count: u16) -> ([u8; SLOT_MAX], usize) {
let mut bytes = [0u8; SLOT_MAX];
bytes[self.item..self.item + size_of::<u32>()].copy_from_slice(&item.to_le_bytes());
bytes[self.count..self.count + size_of::<u16>()].copy_from_slice(&count.to_le_bytes());
(bytes, self.size)
}
}
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",
|| (),
|| unsafe { add_one(res, out) },
);
ret
}
unsafe fn add_one(res: *mut u8, out: *mut u8) {
let Some(slot) = ENGINE.get().and_then(|engine| engine.slot) else {
return;
};
if res.is_null() || out.is_null() {
return;
}
let roll = Roll {
kind: Kinds::DESTROYED,
entity: None,
node: None,
labels: None,
boss: false,
locked: false,
};
let drops = DROPS.get_or_init(Mutex::default).relock();
let Some((item, count, _)) = drops
.iter()
.map(|(_, d)| d)
.filter(|d| d.from.matches(&roll))
.find_map(|d| d.draw(dice()))
else {
return;
};
let (stack, len) = slot.fill(item, count);
unsafe { write_bytes_at(out, 0, &stack[..len]) };
unsafe { write_at(res, 0, 0u8) };
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
.get_or_init(Mutex::default)
.relock()
.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
.get_or_init(Default::default)
.load(Ordering::Acquire)
{
if ENGINE.get().and_then(|engine| engine.slot).is_none() {
return Err(Off::new(format!(
"loot from destroyed things: {} is not on this build",
ids::LOOT_SLOT
)));
}
let roll_one = self.symbol("loot.roll_one")?;
unsafe { self.hook(&ROLL_ONE, roll_one) }?;
ONE_LINKED
.get_or_init(Default::default)
.store(true, Ordering::Release);
}
DROPS
.get_or_init(Mutex::default)
.relock()
.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
.get_or_init(Default::default)
.load(Ordering::Acquire)
{
return Err(
"single-item rolls were not linked at start; they take a restart".to_owned(),
);
}
}
let mut all = DROPS.get_or_init(Mutex::default).relock();
all.retain(|(g, _)| *g != group);
all.extend(drops.into_iter().map(|drop| (group, drop)));
Ok(())
}
#[cfg(test)]
mod tests;