use mlua::{Lua, Result as LuaResult};
use std::path::Path;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum PobError {
#[error("Lua error: {0}")]
Lua(#[from] mlua::Error),
#[error("PoB not initialized")]
NotInitialized,
#[error("Invalid build code: {0}")]
InvalidBuildCode(String),
#[error("Calculation failed: {0}")]
CalculationFailed(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
pub struct PobHeadless {
lua: Lua,
initialized: bool,
pob_src_path: Option<std::path::PathBuf>,
}
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct BuildStats {
#[serde(rename = "dps")]
pub total_dps: f64,
pub effective_hp: f64,
pub life: f64,
pub energy_shield: f64,
pub armour: f64,
pub evasion: f64,
pub fire_res: i32,
pub cold_res: i32,
pub lightning_res: i32,
pub chaos_res: i32,
}
impl PobHeadless {
pub fn new() -> LuaResult<Self> {
let lua = Lua::new();
Ok(Self {
lua,
initialized: false,
pob_src_path: None,
})
}
pub fn init(&mut self, pob_path: &str) -> Result<(), PobError> {
let pob_path = Path::new(pob_path);
let pob_src_path = pob_path.join("src");
let pob_runtime_lua = pob_path.join("runtime/lua");
let original_cwd = std::env::current_dir()?;
std::env::set_current_dir(&pob_src_path)?;
tracing::info!("Initializing PoB from {:?}", pob_src_path);
let runtime_lua_path = pob_runtime_lua
.to_str()
.ok_or_else(|| PobError::CalculationFailed("Invalid path".to_owned()))?;
self.lua
.load(format!(
r#"package.path = package.path .. ";{0}/?.lua;{0}/?/init.lua""#,
runtime_lua_path
))
.exec()?;
self.lua
.load(
r#"
package.preload['lua-utf8'] = function()
local utf8 = {}
utf8.reverse = string.reverse
utf8.gsub = string.gsub
utf8.find = string.find
utf8.sub = string.sub
utf8.match = string.match
utf8.len = string.len
function utf8.next(s, i, offset)
if offset == -1 then
return i > 1 and i - 1 or nil
else
return i < #s and i + 1 or nil
end
end
return utf8
end
"#,
)
.exec()?;
self.lua.load("arg = {}").exec()?;
let result = self.lua.load("dofile('HeadlessWrapper.lua')").exec();
std::env::set_current_dir(original_cwd)?;
result?;
self.initialized = true;
self.pob_src_path = Some(pob_src_path.to_owned());
tracing::info!("PoB headless initialized successfully");
Ok(())
}
pub fn load_build_xml(&self, xml: &str) -> Result<(), PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let result = self.with_pob_cwd(|lua| {
let load_fn: mlua::Function = lua.globals().get("loadBuildFromXML")?;
load_fn.call::<()>((xml, "imported_build"))
})?;
tracing::debug!("Build loaded from XML");
Ok(result)
}
pub fn import_build(&self, _code: &str) -> Result<(), PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
Err(PobError::InvalidBuildCode(
"Build code import not yet implemented (requires Inflate)".to_owned(),
))
}
pub fn calculate(&self) -> Result<BuildStats, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let build: mlua::Table = self.lua.globals().get("build")?;
let calcs_tab: mlua::Table = build.get("calcsTab")?;
let main_output: mlua::Table = calcs_tab.get("mainOutput")?;
let calcs_output: mlua::Table = calcs_tab.get("calcsOutput")?;
let total_dps = main_output
.get::<f64>("TotalDPS")
.or_else(|_| main_output.get::<f64>("CombinedDPS"))
.unwrap_or(0.0);
let life = main_output.get::<f64>("Life").unwrap_or(0.0);
let energy_shield = main_output.get::<f64>("EnergyShield").unwrap_or(0.0);
let armour = main_output.get::<f64>("Armour").unwrap_or(0.0);
let evasion = main_output.get::<f64>("Evasion").unwrap_or(0.0);
let fire_res = main_output.get::<i32>("FireResist").unwrap_or(0);
let cold_res = main_output.get::<i32>("ColdResist").unwrap_or(0);
let lightning_res = main_output.get::<i32>("LightningResist").unwrap_or(0);
let chaos_res = main_output.get::<i32>("ChaosResist").unwrap_or(0);
let effective_hp = calcs_output
.get::<f64>("PhysicalMaximumHitTaken")
.unwrap_or(0.0);
Ok(BuildStats {
total_dps,
effective_hp,
life,
energy_shield,
armour,
evasion,
fire_res,
cold_res,
lightning_res,
chaos_res,
})
}
pub fn query_build_stats(&self) -> Result<serde_json::Value, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let build: mlua::Table = self.lua.globals().get("build")?;
let calcs_tab: mlua::Table = build.get("calcsTab")?;
let main_output: mlua::Table = calcs_tab.get("mainOutput")?;
let calcs_output: mlua::Table = calcs_tab.get("calcsOutput")?;
let offense_fields = &[
"TotalDPS",
"CombinedDPS",
"AverageHit",
"Speed",
"CritChance",
"CritMultiplier",
"HitChance",
"TotalDot",
"BleedDPS",
"IgniteDPS",
"PoisonDPS",
"FullDPS",
"WithPoisonDPS",
"WithIgniteDPS",
"WithBleedDPS",
"TotalDotDPS",
"Damage",
"PhysicalDamage",
"ElementalDamage",
"FireDamage",
"ColdDamage",
"LightningDamage",
"ChaosDamage",
];
let defense_fields = &[
"TotalEHP",
"PhysicalMaximumHitTaken",
"FireMaximumHitTaken",
"ColdMaximumHitTaken",
"LightningMaximumHitTaken",
"ChaosMaximumHitTaken",
"Armour",
"PhysicalDamageReduction",
"Evasion",
"EvadeChance",
"BlockChance",
"SpellBlockChance",
"SpellSuppressionChance",
"FireResist",
"ColdResist",
"LightningResist",
"ChaosResist",
"FireResistOverCap",
"ColdResistOverCap",
"LightningResistOverCap",
"ChaosResistOverCap",
];
let resource_fields = &[
"Life",
"LifeUnreserved",
"LifeRegenRecovery",
"Mana",
"ManaUnreserved",
"ManaRegenRecovery",
"EnergyShield",
"EnergyShieldRegenRecovery",
"Spirit",
];
let speed_fields = &[
"EffectiveMovementSpeedMod",
"AreaOfEffectRadiusMetres",
"Duration",
"ManaCost",
];
let charge_fields = &[
"PowerChargesMax",
"FrenzyChargesMax",
"EnduranceChargesMax",
];
let offense = read_fields(&main_output, offense_fields);
let mut defense = read_fields(&main_output, defense_fields);
merge_fields(&mut defense, &read_fields(&calcs_output, defense_fields));
let resources = read_fields(&main_output, resource_fields);
let speed = read_fields(&main_output, speed_fields);
let charges = read_fields(&main_output, charge_fields);
Ok(serde_json::json!({
"offense": offense,
"defense": defense,
"resources": resources,
"speed": speed,
"charges": charges,
}))
}
pub fn query_skill_list(&self) -> Result<serde_json::Value, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let build: mlua::Table = self.lua.globals().get("build")?;
let calcs_tab: mlua::Table = build.get("calcsTab")?;
let main_output: mlua::Table = calcs_tab.get("mainOutput")?;
let mut skill_dps_list = Vec::new();
if let Ok(skill_dps_table) = main_output.get::<mlua::Table>("SkillDPS") {
let len = skill_dps_table.raw_len();
for i in 1..=len {
if let Ok(entry) = skill_dps_table.get::<mlua::Table>(i) {
let mut skill = serde_json::Map::new();
if let Ok(name) = entry.get::<String>("name") {
skill.insert("name".to_owned(), serde_json::Value::String(name));
}
if let Ok(dps) = entry.get::<f64>("dps") {
skill.insert(
"dps".to_owned(),
serde_json::Value::Number(
serde_json::Number::from_f64(dps)
.unwrap_or_else(|| serde_json::Number::from(0)),
),
);
}
if let Ok(count) = entry.get::<i64>("count") {
skill.insert(
"count".to_owned(),
serde_json::Value::Number(count.into()),
);
}
if let Ok(trigger) = entry.get::<String>("trigger") {
skill.insert("trigger".to_owned(), serde_json::Value::String(trigger));
}
if let Ok(skill_part) = entry.get::<String>("skillPart") {
skill.insert(
"skillPart".to_owned(),
serde_json::Value::String(skill_part),
);
}
if !skill.is_empty() {
skill_dps_list.push(serde_json::Value::Object(skill));
}
}
}
}
let mut socket_groups = Vec::new();
if let Ok(skills_tab) = build.get::<mlua::Table>("skillsTab") {
if let Ok(group_list) = skills_tab.get::<mlua::Table>("socketGroupList") {
let len = group_list.raw_len();
for i in 1..=len {
if let Ok(group) = group_list.get::<mlua::Table>(i) {
let mut group_obj = serde_json::Map::new();
if let Ok(label) = group.get::<String>("displayLabel") {
group_obj.insert(
"label".to_owned(),
serde_json::Value::String(label),
);
}
if let Ok(enabled) = group.get::<bool>("enabled") {
group_obj
.insert("enabled".to_owned(), serde_json::Value::Bool(enabled));
}
if let Ok(slot) = group.get::<String>("slot") {
group_obj.insert("slot".to_owned(), serde_json::Value::String(slot));
}
let mut gems = Vec::new();
if let Ok(gem_list) = group.get::<mlua::Table>("gemList") {
let gem_len = gem_list.raw_len();
for j in 1..=gem_len {
if let Ok(gem) = gem_list.get::<mlua::Table>(j) {
let mut gem_obj = serde_json::Map::new();
if let Ok(name) = gem.get::<String>("nameSpec") {
gem_obj.insert(
"name".to_owned(),
serde_json::Value::String(name),
);
}
if let Ok(level) = gem.get::<i64>("level") {
gem_obj.insert(
"level".to_owned(),
serde_json::Value::Number(level.into()),
);
}
if let Ok(quality) = gem.get::<i64>("quality") {
gem_obj.insert(
"quality".to_owned(),
serde_json::Value::Number(quality.into()),
);
}
if let Ok(enabled) = gem.get::<bool>("enabled") {
gem_obj.insert(
"enabled".to_owned(),
serde_json::Value::Bool(enabled),
);
}
if !gem_obj.is_empty() {
gems.push(serde_json::Value::Object(gem_obj));
}
}
}
}
if !gems.is_empty() {
group_obj
.insert("gems".to_owned(), serde_json::Value::Array(gems));
}
if !group_obj.is_empty() {
socket_groups.push(serde_json::Value::Object(group_obj));
}
}
}
}
}
Ok(serde_json::json!({
"skill_dps": skill_dps_list,
"socket_groups": socket_groups,
}))
}
pub fn query_config(&self) -> Result<serde_json::Value, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let build: mlua::Table = self.lua.globals().get("build")?;
let config_tab: mlua::Table = build.get("configTab")?;
let input: mlua::Table = config_tab.get("input")?;
let mut config = serde_json::Map::new();
for pair in input.pairs::<String, mlua::Value>() {
let (key, value) = pair?;
let json_val = match value {
mlua::Value::Boolean(b) => serde_json::Value::Bool(b),
mlua::Value::Integer(n) => serde_json::Value::Number(n.into()),
mlua::Value::Number(n) => {
if let Some(num) = serde_json::Number::from_f64(n) {
serde_json::Value::Number(num)
} else {
continue;
}
}
mlua::Value::String(s) => {
serde_json::Value::String(
s.to_str().map(|s| s.to_owned()).unwrap_or_default(),
)
}
_ => continue,
};
config.insert(key, json_val);
}
Ok(serde_json::Value::Object(config))
}
pub fn query_item(&self, slot: &str) -> Result<serde_json::Value, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let build: mlua::Table = self.lua.globals().get("build")?;
let items_tab: mlua::Table = build.get("itemsTab")?;
let active_item_set: mlua::Table = items_tab.get("activeItemSet")?;
let slot_entry: mlua::Table = match active_item_set.get::<mlua::Table>(slot) {
Ok(t) => t,
Err(_) => return Ok(serde_json::json!({ "slot": slot, "empty": true })),
};
let sel_item_id: i64 = slot_entry.get::<i64>("selItemId").unwrap_or(0);
if sel_item_id == 0 {
return Ok(serde_json::json!({ "slot": slot, "empty": true }));
}
let items: mlua::Table = items_tab.get("items")?;
let item: mlua::Table = match items.get::<mlua::Table>(sel_item_id) {
Ok(t) => t,
Err(_) => return Ok(serde_json::json!({ "slot": slot, "empty": true })),
};
let name = item.get::<String>("name").unwrap_or_default();
let base_name = item
.get::<mlua::Table>("base")
.and_then(|b| b.get::<String>("name"))
.unwrap_or_default();
let rarity = item.get::<String>("rarity").unwrap_or_default();
let quality = item.get::<i64>("quality").unwrap_or(0);
let spirit = item.get::<i64>("spiritValue").unwrap_or(0);
let sockets = item.get::<i64>("itemSocketCount").unwrap_or(0);
let implicits = read_mod_lines(&item, "implicitModLines");
let explicits = read_mod_lines(&item, "explicitModLines");
let enchants = read_mod_lines(&item, "enchantModLines");
let runes = read_mod_lines(&item, "runeModLines");
Ok(serde_json::json!({
"slot": slot,
"name": name,
"base": base_name,
"rarity": rarity,
"quality": quality,
"spirit": spirit,
"sockets": sockets,
"implicits": implicits,
"explicits": explicits,
"enchants": enchants,
"runes": runes,
}))
}
pub fn query_jewel(&self, socket_id: i64) -> Result<serde_json::Value, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let build: mlua::Table = self.lua.globals().get("build")?;
let spec: mlua::Table = build.get("spec")?;
let jewels: mlua::Table = match spec.get::<mlua::Table>("jewels") {
Ok(t) => t,
Err(_) => return Ok(serde_json::json!({ "socket_id": socket_id, "empty": true })),
};
let item_id: i64 = match jewels.get::<i64>(socket_id) {
Ok(id) if id != 0 => id,
_ => return Ok(serde_json::json!({ "socket_id": socket_id, "empty": true })),
};
let items_tab: mlua::Table = build.get("itemsTab")?;
let items: mlua::Table = items_tab.get("items")?;
let item: mlua::Table = match items.get::<mlua::Table>(item_id) {
Ok(t) => t,
Err(_) => return Ok(serde_json::json!({ "socket_id": socket_id, "empty": true })),
};
let name = item.get::<String>("name").unwrap_or_default();
let base_name = item
.get::<mlua::Table>("base")
.and_then(|b| b.get::<String>("name"))
.unwrap_or_default();
let rarity = item.get::<String>("rarity").unwrap_or_default();
let quality = item.get::<i64>("quality").unwrap_or(0);
let implicits = read_mod_lines(&item, "implicitModLines");
let explicits = read_mod_lines(&item, "explicitModLines");
let enchants = read_mod_lines(&item, "enchantModLines");
let runes = read_mod_lines(&item, "runeModLines");
Ok(serde_json::json!({
"socket_id": socket_id,
"name": name,
"base": base_name,
"rarity": rarity,
"quality": quality,
"implicits": implicits,
"explicits": explicits,
"enchants": enchants,
"runes": runes,
}))
}
pub fn query_passive_tree(&self) -> Result<serde_json::Value, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
let build: mlua::Table = self.lua.globals().get("build")?;
let spec: mlua::Table = build.get("spec")?;
let class_name = spec.get::<String>("curClassName").unwrap_or_default();
let ascendancy_name = spec.get::<String>("curAscendClassName").unwrap_or_default();
let alloc_nodes: mlua::Table = spec.get("allocNodes")?;
let mut keystones = Vec::new();
let mut notables = Vec::new();
let mut ascendancy_nodes = Vec::new();
let mut masteries = Vec::new();
let mut jewel_sockets = Vec::new();
let mut total_allocated: u32 = 0;
for pair in alloc_nodes.pairs::<mlua::Value, mlua::Table>() {
let (key, node) = pair?;
total_allocated += 1;
let node_type = node.get::<String>("type").unwrap_or_default();
let name = node.get::<String>("dn").unwrap_or_default();
let asc_name: Option<String> = node.get::<String>("ascendancyName").ok();
if asc_name.is_some() {
let stats = read_node_stats(&node);
let mut entry = serde_json::json!({
"name": name,
"type": node_type,
});
if !stats.is_empty() {
entry["stats"] = serde_json::Value::Array(
stats.into_iter().map(serde_json::Value::String).collect(),
);
}
ascendancy_nodes.push(entry);
continue;
}
match node_type.as_str() {
"Keystone" => {
let stats = read_node_stats(&node);
let mut entry = serde_json::json!({ "name": name });
if !stats.is_empty() {
entry["stats"] = serde_json::Value::Array(
stats.into_iter().map(serde_json::Value::String).collect(),
);
}
keystones.push(entry);
}
"Notable" => {
let stats = read_node_stats(&node);
let mut entry = serde_json::json!({ "name": name });
if !stats.is_empty() {
entry["stats"] = serde_json::Value::Array(
stats.into_iter().map(serde_json::Value::String).collect(),
);
}
notables.push(entry);
}
"Mastery" => {
let stats = read_node_stats(&node);
let mut entry = serde_json::json!({ "name": name });
if !stats.is_empty() {
entry["stats"] = serde_json::Value::Array(
stats.into_iter().map(serde_json::Value::String).collect(),
);
}
masteries.push(entry);
}
"Socket" => {
let node_id = lua_value_to_i64(&key).unwrap_or(0);
jewel_sockets.push(serde_json::json!({
"node_id": node_id,
"name": name,
}));
}
_ => {}
}
}
Ok(serde_json::json!({
"class": class_name,
"ascendancy": ascendancy_name,
"total_allocated": total_allocated,
"keystones": keystones,
"notables": notables,
"ascendancy_nodes": ascendancy_nodes,
"masteries": masteries,
"jewel_sockets": jewel_sockets,
}))
}
fn with_pob_cwd<F, R>(&self, f: F) -> Result<R, PobError>
where
F: FnOnce(&Lua) -> LuaResult<R>,
{
let pob_src = self
.pob_src_path
.as_ref()
.ok_or(PobError::NotInitialized)?;
let original_cwd = std::env::current_dir()?;
std::env::set_current_dir(pob_src)?;
let result = f(&self.lua);
std::env::set_current_dir(original_cwd)?;
Ok(result?)
}
pub fn export_build(&self) -> Result<String, PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
Ok(String::new())
}
pub fn set_passive_tree(&self, _tree_data: &str) -> Result<(), PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
Ok(())
}
pub fn set_main_skill(&self, _skill_name: &str) -> Result<(), PobError> {
if !self.initialized {
return Err(PobError::NotInitialized);
}
Ok(())
}
}
impl Default for PobHeadless {
fn default() -> Self {
Self::new().expect("Failed to create Lua runtime")
}
}
fn read_fields(table: &mlua::Table, fields: &[&str]) -> serde_json::Map<String, serde_json::Value> {
let mut map = serde_json::Map::new();
for &field in fields {
if let Ok(v) = table.get::<f64>(field) {
if v != 0.0 {
if let Some(num) = serde_json::Number::from_f64(v) {
map.insert(field.to_owned(), serde_json::Value::Number(num));
}
}
} else if let Ok(v) = table.get::<i64>(field) {
if v != 0 {
map.insert(
field.to_owned(),
serde_json::Value::Number(v.into()),
);
}
}
}
map
}
fn read_mod_lines(item: &mlua::Table, field: &str) -> Vec<String> {
let table = match item.get::<mlua::Table>(field) {
Ok(t) => t,
Err(_) => return Vec::new(),
};
(1..=table.raw_len())
.filter_map(|i| table.get::<mlua::Table>(i).ok())
.filter_map(|entry| entry.get::<String>("line").ok())
.collect()
}
fn merge_fields(
dst: &mut serde_json::Map<String, serde_json::Value>,
src: &serde_json::Map<String, serde_json::Value>,
) {
for (key, value) in src {
dst.entry(key.clone()).or_insert_with(|| value.clone());
}
}
fn read_node_stats(node: &mlua::Table) -> Vec<String> {
let table = match node.get::<mlua::Table>("sd") {
Ok(t) => t,
Err(_) => return Vec::new(),
};
(1..=table.raw_len())
.filter_map(|i| table.get::<String>(i).ok())
.collect()
}
fn lua_value_to_i64(val: &mlua::Value) -> Option<i64> {
match val {
mlua::Value::Integer(n) => Some(*n),
mlua::Value::Number(n) => Some(*n as i64),
_ => None,
}
}