Something went wrong. Try again.
A DPS meter for AION 2
Something went wrong. Try again.
22 kB · 670 lines
Rust
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671//! Entities coming and going, and who they are.//!//! Players are described by records with a shared layout://!//! ```text//! entity varint//! (mask) u32//! (mask) u8 bit 0 set when a name follows//! name u8 length, then UTF-8//! ```//!//! What follows the name depends on the record://!//! ```text//! player (45 36) self (33 36)//! class u32 server u16//! class u32//! (unknown) u8//! level u32//! ```//!//! Each class has a few values in the class field (see [`Class::from_field`]).//!//! A player's gear score and combat power are further on, past fields whose//! lengths aren't understood, so each is found by the shape around it. The gear//! score follows a list of stats, the last of which has the key `27 02`://!//! ```text//! (stat) u16 key, i32 value//! level u32//! (unknown) u32 always 0 so far//! gear score u32//! ```//!//! Combat power is near the end, after a marker whose last three bytes are//! `F4 CB 1F`. Up to two u32s whose meanings aren't known can come between//! them, and zeros follow it://!//! ```text//! (marker) u32//! (unknown) u32 × 0..=2//! power u32//! (zeros)//! ```//!//! The self record has the gear score right after the level, twice. It has the//! marker too, but its combat power comes later, as two u64s that have been//! the same. The server sends the combat power of the character being played//! again (`56 36`) as it changes, like as a zone loads://!//! ```text//! power u64//! (unknown) u64 the same as the power, once it has settled//! ```//!//! Everything else that comes into view has a spawn record (`41 36`)://!//! ```text//! entity varint//! kind u8 see `SpawnKind`//! (unknown) u8//! (flags) u8 bit 0 set when a summoner's name follows//! summoner u8 length, then UTF-8//! template u32 what it is, like which kind of monster//! (flags) u8 bit 3 set when more follows, in a layout that//! isn't understood//! (unknown) u8 always 2 so far//! position f32 × 3//! heading f32//! (unknown) u16//! (unknown) u8 always 1 so far//! HP varint//! max HP varint//! ```//!//! Only some summons name their summoner. Without bit 3 of the flags, the HP//! comes right after the heading; with it, where it is isn't known.//!//! The game sends a player's record when they come into view, so players who//! were already in view when a capture started have none. It sends the self//! record, for the character being played, when a zone loads.
use crate::reader::Reader;use crate::stream::Opcode;use crate::{Class, DecodeError, EntityId};
pub const PLAYER_OPCODE: Opcode = Opcode([0x45, 0x36]);pub const SELF_OPCODE: Opcode = Opcode([0x33, 0x36]);pub const SPAWN_OPCODE: Opcode = Opcode([0x41, 0x36]);pub const DESPAWN_OPCODE: Opcode = Opcode([0x42, 0x36]);pub const SELF_POWER_OPCODE: Opcode = Opcode([0x56, 0x36]);
/// The most characters a name can have.const MAX_NAME_CHARS: usize = 12;
/// The marker that begins the placeholder name of a character still in the/// tutorial, before it has been named.const PLACEHOLDER_NAME_MARKER: char = '$';
/// The key of the last stat before a player's level and gear score.const LAST_STAT_KEY: [u8; 2] = [0x27, 0x02];
/// The last bytes of the marker before a player's combat power. Its first byte/// varies.const COMBAT_POWER_MARKER: [u8; 3] = [0xF4, 0xCB, 0x1F];
/// The bit of a spawn's flags set when more follows its template, in a layout/// that isn't understood.const SPAWN_MORE: u8 = 0x08;
/// The levels a character can have.const LEVELS: std::ops::RangeInclusive<u32> = 1..=99;
/// Another player, come into view.#[derive(Debug, Clone, PartialEq, Eq)]pub struct Player { pub entity: EntityId, pub name: Option<String>, pub class: Option<Class>, pub gear_score: Option<u32>, pub combat_power: Option<u32>,}
/// The character being played.#[derive(Debug, Clone, PartialEq, Eq)]pub struct Myself { pub entity: EntityId, /// `None` for a character that hasn't been named yet. pub name: Option<String>, pub server: u16, pub class: Option<Class>, pub level: Option<u32>, pub gear_score: Option<u32>, pub combat_power: Option<u32>,}
/// The combat power of the character being played, as it changes.#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub struct SelfPower { pub combat_power: u32,}
/// Something other than a player, come into view: a monster, a summon, a/// skill's effect.#[derive(Debug, Clone, PartialEq, Eq)]pub struct Spawn { pub entity: EntityId, pub kind: SpawnKind, /// The name of the player who summoned it, for some summons. pub summoner: Option<String>, /// What it is. Every one of a kind of monster has the same template. pub template: Template, /// Its HP as it came into view, where the record says. pub health: Option<Health>,}
/// An entity's HP, out of its most.#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub struct Health { pub current: u64, pub max: u64,}
/// What a [`Spawn`] is, like which kind of monster.#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]pub struct Template(pub u32);
/// What kind of thing a [`Spawn`] is. Only some values are known.#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]pub struct SpawnKind(pub u8);
impl SpawnKind { pub const SUMMON: Self = Self(0x5F);}
/// Something going out of view.#[derive(Debug, Clone, Copy, PartialEq, Eq)]pub struct Despawn { pub entity: EntityId, /// Why. Not yet understood. pub reason: u8,}
pub fn decode_player(body: &[u8]) -> Result<Player, DecodeError> { let mut reader = Reader::new(body); let (entity, name) = read_entity_and_name(&mut reader)?; let class = match name { Some(_) => Class::from_field(reader.u32_le()?), None => None, }; Ok(Player { entity, name: name.map(str::to_string), class, gear_score: find_gear_score(body), combat_power: find_combat_power(body), })}
pub fn decode_myself(body: &[u8]) -> Result<Myself, DecodeError> { let mut reader = Reader::new(body); let (entity, name) = read_entity_and_name(&mut reader)?; if name.is_none() { return Err(DecodeError::Invalid("a self record without a name")); } let server = reader.u16_le()?; let class = Class::from_field(reader.u32_le()?); reader.u8()?; let level = reader.u32_le().ok().filter(|level| LEVELS.contains(level)); let gear_score = level.and_then(|_| reader.u32_le().ok());
let name = name.filter(|name| !name.starts_with(PLACEHOLDER_NAME_MARKER)); Ok(Myself { entity, name: name.map(str::to_string), server, class, level, gear_score, combat_power: find_own_combat_power(body), })}
pub fn decode_self_power(body: &[u8]) -> Result<SelfPower, DecodeError> { let mut reader = Reader::new(body); let combat_power = read_u64_power(&mut reader)?; reader.skip(8)?; Ok(SelfPower { combat_power })}
pub fn decode_spawn(body: &[u8]) -> Result<Spawn, DecodeError> { let mut reader = Reader::new(body); let entity = EntityId(reader.varint()?); let kind = SpawnKind(reader.u8()?); reader.u8()?; let has_summoner = reader.u8()? & 0x01 != 0; let summoner = if has_summoner { Some(read_name(&mut reader)?.to_string()) } else { None }; let template = Template(reader.u32_le()?); // What follows the template isn't needed to know what spawned, so a spawn // whose HP can't be read is still one. let health = read_spawn_health(&mut reader).ok().flatten(); Ok(Spawn { entity, kind, summoner, template, health, })}
/// Reads a spawn's HP, after its template. `None` for a record laid out in a/// way that doesn't say where it is.fn read_spawn_health(reader: &mut Reader) -> Result<Option<Health>, DecodeError> { if reader.u8()? & SPAWN_MORE != 0 { return Ok(None); } reader.u8()?; // The position and heading. reader.skip(16)?; reader.u16_le()?; reader.u8()?; let current = u64::from(reader.varint()?); let max = u64::from(reader.varint()?); if current > max { return Err(DecodeError::Invalid("more HP than the most")); } Ok(Some(Health { current, max }))}
pub fn decode_despawn(body: &[u8]) -> Result<Despawn, DecodeError> { let mut reader = Reader::new(body); let entity = EntityId(reader.varint()?); reader.u8()?; let reason = reader.u8()?; Ok(Despawn { entity, reason })}
/// Finds a player's gear score, after the last of their stats and their level.fn find_gear_score(body: &[u8]) -> Option<u32> { let marker = find_marker(body).unwrap_or(body.len()); (0..marker) .rev() .filter(|&at| body[at..].starts_with(&LAST_STAT_KEY)) .find_map(|at| { let mut reader = Reader::new(body.get(at + 6..)?); let level = reader.u32_le().ok()?; let unknown = reader.u32_le().ok()?; (LEVELS.contains(&level) && unknown == 0).then_some(())?; reader.u32_le().ok() })}
/// Finds a player's combat power: the last of the u32s after the marker,/// before the zeros that end the record.fn find_combat_power(body: &[u8]) -> Option<u32> { let mut reader = Reader::new(&body[find_marker(body)? + COMBAT_POWER_MARKER.len()..]); let mut last = None; while let Ok(value) = reader.u32_le() { if value == 0 { break; } last = Some(value); } last}
/// Finds the combat power of the character being played, in their self/// record: the first value after the marker that's written twice in a row,/// as u64s.fn find_own_combat_power(body: &[u8]) -> Option<u32> { let doubled_at = |at: usize| { let mut reader = Reader::new(body.get(at..)?); let first = read_u64_power(&mut reader).ok()?; let second = read_u64_power(&mut reader).ok()?; (first != 0 && first == second).then_some(first) }; let after = find_marker(body)? + COMBAT_POWER_MARKER.len(); let mut at = (after..body.len()).find(|&at| doubled_at(at).is_some())?; // A zero byte before the power reads as it, shifted, written twice too. while doubled_at(at + 1).is_some() { at += 1; } doubled_at(at)}
/// Where the last bytes of the marker before combat power are.fn find_marker(body: &[u8]) -> Option<usize> { body.windows(COMBAT_POWER_MARKER.len()) .rposition(|window| window == COMBAT_POWER_MARKER)}
/// Reads combat power written as a u64. One that doesn't fit in a `u32` isn't/// one.fn read_u64_power(reader: &mut Reader) -> Result<u32, DecodeError> { let low = reader.u32_le()?; if reader.u32_le()? != 0 { return Err(DecodeError::Invalid("a combat power that doesn't fit")); } Ok(low)}
/// Reads the entity and name at the start of a player's record. A name that/// isn't one means the record isn't laid out as expected.fn read_entity_and_name<'a>( reader: &mut Reader<'a>,) -> Result<(EntityId, Option<&'a str>), DecodeError> { let entity = EntityId(reader.varint()?); reader.u32_le()?; let has_name = reader.u8()? & 0x01 != 0; if !has_name { return Ok((entity, None)); }
Ok((entity, Some(read_name(reader)?)))}
/// Reads a character's name, as a length and UTF-8. A name that isn't one/// means the record isn't laid out as expected.pub(crate) fn read_name<'a>(reader: &mut Reader<'a>) -> Result<&'a str, DecodeError> { let len = usize::from(reader.u8()?); let name = std::str::from_utf8(reader.bytes(len)?) .map_err(|_| DecodeError::Invalid("a name that isn't UTF-8"))?; if !is_name(name) { return Err(DecodeError::Invalid("a name that isn't a character's")); } Ok(name)}
/// Whether `name` could be a character's: letters and digits, with at least/// one letter. The tutorial's placeholder names are allowed their marker.fn is_name(name: &str) -> bool { let name = name.strip_prefix(PLACEHOLDER_NAME_MARKER).unwrap_or(name); let chars = name.chars().count(); (1..=MAX_NAME_CHARS).contains(&chars) && name.chars().all(char::is_alphanumeric) && name.chars().any(char::is_alphabetic)}
/// Builders for encoded records, for tests.#[cfg(any(test, feature = "test-support"))]pub mod encode { use super::*; use crate::varint;
fn entity_and_name(entity: EntityId, name: Option<&str>, bytes: &mut Vec<u8>) { varint::write(entity.0, bytes); bytes.extend([0x15, 0x30, 0x24, 0x00]); match name { Some(name) => { bytes.push(0x07); bytes.push(name.len() as u8); bytes.extend(name.as_bytes()); } None => bytes.push(0x06), } }
/// A player record, with `class_field` as its class. pub fn player(entity: EntityId, name: Option<&str>, class_field: u32) -> Vec<u8> { let mut bytes = Vec::new(); entity_and_name(entity, name, &mut bytes); if name.is_some() { bytes.extend(class_field.to_le_bytes()); bytes.extend([0x01, 0x02, 0x00, 0x12]); } bytes }
/// A self record. pub fn myself( entity: EntityId, name: &str, server: u16, class_field: u32, level: u32, ) -> Vec<u8> { let mut bytes = Vec::new(); entity_and_name(entity, Some(name), &mut bytes); bytes.extend(server.to_le_bytes()); bytes.extend(class_field.to_le_bytes()); bytes.push(0x00); bytes.extend(level.to_le_bytes()); bytes }}
#[cfg(test)]mod tests { use pretty_assertions::assert_eq;
use super::*;
#[test] fn test_decode_player() { assert_eq!( decode_player(&encode::player(EntityId(12_431), Some("Ariel"), 6)), Ok(Player { entity: EntityId(12_431), name: Some("Ariel".into()), class: Some(Class::Gladiator), gear_score: None, combat_power: None, }) ); }
/// The part of a player record from their character to its end, from a /// capture of 2026-10-12, with the stretch that isn't understood cut short. fn player_tail(stats: &str, marker: &str) -> Vec<u8> { let mut tail = hex(&format!( "0E 01 01 FC 28 04 00 00 00 38 08 {stats} DE 02 0C 64 64 65 21" )); tail.extend([0x00; 20]); tail.extend(hex(marker)); tail }
fn hex(text: &str) -> Vec<u8> { let digits: Vec<u8> = text.bytes().filter(u8::is_ascii_hexdigit).collect(); digits .chunks(2) .map(|pair| u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).unwrap()) .collect() }
#[test] fn test_decode_player_power() { let mut body = encode::player(EntityId(9_157), Some("Barbara"), 32); body.extend(player_tail( "04 CD 00 4F 00 00 00 CE 00 60 F0 FF FF D0 00 3C 01 00 00 27 02 48 F4 FF FF 2D 00 00 00 00 00 00 00 B6 05 00 00 16 04 00 00", "21 F4 CB 1F 16 04 00 00 13 00 00 00 60 10 01 00 00 00 00 00 00 00", )); let player = decode_player(&body).unwrap(); assert_eq!(player.gear_score, Some(1_462)); assert_eq!(player.combat_power, Some(69_728)); }
#[test] fn test_decode_player_power_with_fewer_fields() { // A low-level player's, with more stats and less before their power. let mut body = encode::player(EntityId(1), Some("Ariel"), 6); body.extend(player_tail( "05 CD 00 0A 00 00 00 CE 00 60 F0 FF FF D0 00 33 01 00 00 D6 00 32 00 00 00 27 02 48 F4 FF FF 0E 00 00 00 00 00 00 00 4D 00 00 00", "26 F4 CB 1F 66 1C 00 00 00 00 00 00 00", )); let player = decode_player(&body).unwrap(); assert_eq!(player.gear_score, Some(77)); assert_eq!(player.combat_power, Some(7_270)); }
#[test] fn test_decode_player_with_a_name_in_hangul() { let player = decode_player(&encode::player(EntityId(1), Some("검성이"), 30)).unwrap(); assert_eq!(player.name.as_deref(), Some("검성이")); assert_eq!(player.class, Some(Class::Cleric)); }
#[test] fn test_decode_player_without_a_name() { assert_eq!( decode_player(&encode::player(EntityId(1), None, 0)), Ok(Player { entity: EntityId(1), name: None, class: None, gear_score: None, combat_power: None, }) ); }
#[test] fn test_rejects_a_name_that_isnt_one() { assert_eq!( decode_player(&encode::player(EntityId(1), Some("a b"), 6)), Err(DecodeError::Invalid("a name that isn't a character's")) ); assert_eq!( decode_player(&encode::player(EntityId(1), Some("1234"), 6)), Err(DecodeError::Invalid("a name that isn't a character's")) ); }
#[test] fn test_decode_myself() { assert_eq!( decode_myself(&encode::myself(EntityId(12_431), "Ariel", 1_017, 7, 30)), Ok(Myself { entity: EntityId(12_431), name: Some("Ariel".into()), server: 1_017, class: Some(Class::Gladiator), level: Some(30), gear_score: None, combat_power: None, }) ); }
#[test] fn test_decode_myself_power() { let mut body = encode::myself(EntityId(4_467), "Egress", 1_105, 30, 45); body.extend(hex("FB 05 00 00 2D 00 00 00")); // From a capture of 2026-10-12, from the marker on, with timestamps // and an unknown stretch between them. body.extend(hex(" 03 21 F4 CB 1F 98 39 00 00 00 00 00 00 F1 F9 BA 2C A1 01 00 00 00 00 00 11 8F 2C 00 02 39 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 57 A7 D0 22 A1 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 F6 19 01 00 00 00 00 00 F6 19 01 00 00 00 00 00 05 01 50 01 02 00 00 03 ")); let myself = decode_myself(&body).unwrap(); assert_eq!(myself.gear_score, Some(1_531)); assert_eq!(myself.combat_power, Some(72_182)); }
#[test] fn test_decode_self_power() { // From a capture of 2026-10-12, as a zone loaded. assert_eq!( decode_self_power(&hex("18 EB 00 00 00 00 00 00 F6 19 01 00 00 00 00 00")), Ok(SelfPower { combat_power: 60_184 }) ); }
#[test] fn test_decode_myself_in_the_tutorial() { let myself = decode_myself(&encode::myself( EntityId(3_877), "$Kc03nyeQHr4", 1_017, 7, 1, )) .unwrap(); assert_eq!(myself.entity, EntityId(3_877)); assert_eq!(myself.name, None); }
#[test] fn test_decode_spawn() { // From a capture of 2026-10-12: a training scarecrow. let body = [ 0xB9, 0xA2, 0x02, 0x04, 0x20, 0x00, 0x20, 0x9F, 0x24, 0x00, 0x40, 0x02, ]; assert_eq!( decode_spawn(&body), Ok(Spawn { entity: EntityId(37_177), kind: SpawnKind(0x04), summoner: None, template: Template(2_400_032), health: None, }) ); }
#[test] fn test_decode_a_summon() { // From a capture of 2026-10-12. let mut body = vec![0xBC, 0xC8, 0x02, 0x5F, 0x00, 0x01, 0x06]; body.extend(b"Egress"); body.extend([0xC0, 0x90, 0x2C, 0x00, 0x40, 0x02]); assert_eq!( decode_spawn(&body), Ok(Spawn { entity: EntityId(42_044), kind: SpawnKind::SUMMON, summoner: Some("Egress".into()), template: Template(2_920_640), health: None, }) ); }
#[test] fn test_decode_spawn_health() { // From a capture of 2026-10-12: a dungeon boss, with what follows its // HP cut short. let body = [ 0xD4, 0x88, 0x01, 0x0C, 0x20, 0x00, 0x3A, 0x19, 0x23, 0x00, 0x00, 0x02, 0x00, 0xCA, 0xA2, 0x47, 0x00, 0x10, 0x3B, 0xC7, 0x00, 0xFC, 0x0D, 0x47, 0x00, 0xD8, 0x3D, 0x43, 0x00, 0x87, 0x01, 0xA0, 0xE7, 0xD9, 0x04, 0xA0, 0xE7, 0xD9, 0x04, 0x64, 0x00, ]; assert_eq!( decode_spawn(&body), Ok(Spawn { entity: EntityId(17_492), kind: SpawnKind(0x0C), summoner: None, template: Template(2_300_218), health: Some(Health { current: 9_860_000, max: 9_860_000, }), }) ); }
#[test] fn test_decode_spawn_health_in_an_unknown_layout() { // From a capture of 2026-10-12, cut short. let body = [ 0xBF, 0xBB, 0x02, 0x5D, 0x00, 0x00, 0xBF, 0x8E, 0x2C, 0x00, 0x08, 0x02, 0x11, 0xE9, 0x28, 0xC7, 0xB9, 0x41, 0x61, 0x47, 0xEC, 0x3B, 0x13, 0x46, 0x31, 0xB1, 0xDF, 0x42, 0x89, 0x4F, 0x28, 0xD3, 0xBA, 0xC3, 0xF4, 0x00, ]; assert_eq!(decode_spawn(&body).unwrap().health, None); }
#[test] fn test_decode_despawn() { // From a capture of 2026-10-12. assert_eq!( decode_despawn(&[0xBC, 0xC8, 0x02, 0x00, 0x03]), Ok(Despawn { entity: EntityId(42_044), reason: 3, }) ); }}