feat(portable): local canonical storage for portable characters (Phase 1)

Adds crates/coa-core/src/portable: UUIDv7 identities, namespaced content ids,
the v1 portable character model (validated, bounded, canonical), compressed
hashed snapshots, a union-only collection set, and a SQLite (WAL) store with
revisions, stale-revision rejection, rollback-as-new-revision, configurable
history, realm/item mappings and forward-only migrations.

No realm database, network or UI is touched; no existing behaviour changes.
45 new tests. Format documented in docs/PORTABLE_FORMAT.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ДмитрийandClaude Sonnet 5.5 committed 2026-10-06 07:13:14 +05:00
1 parent 9e84763f14
commit efd4b8bbf2
15 files changed
+3039 -1

No files matched your search

Generated
+82
View File
@@ -502,6 +502,7 @@ dependencies = [
"num-bigint",
"rand_core",
"reqwest 0.12.28",
"rusqlite",
"serde",
"serde_json",
"sha1",
@@ -1100,6 +1101,18 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -1613,11 +1626,32 @@ version = "0.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "heck"
@@ -2244,6 +2278,17 @@ dependencies = [
"libc",
]
[[package]]
name = "libsqlite3-sys"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -3294,6 +3339,31 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.21",
]
[[package]]
name = "rusqlite"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"bitflags 2.13.2",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
name = "rustc-hash"
version = "2.1.3"
@@ -3869,6 +3939,18 @@ dependencies = [
"der",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "stable_deref_trait"
version = "1.2.1"
+2 -1
View File
@@ -10,7 +10,7 @@ serde_json = "1"
sha2 = "0.10"
hex = "0.4"
thiserror = "2"
uuid = { version = "1", features = ["v4", "serde"] }
uuid = { version = "1", features = ["v4", "v7", "serde"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
dunce = "1"
fs4 = "0.13"
@@ -25,6 +25,7 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
sha1 = "0.10"
tracing-subscriber = { version = "0.3", default-features = false, features = ["fmt", "std"] }
tempfile = "3"
rusqlite = { version = "0.40.2", features = ["bundled"] }
[dev-dependencies]
rand_core = { version = "0.6", features = ["getrandom"] }
+1
View File
@@ -25,6 +25,7 @@ pub mod population;
pub mod process;
pub mod manifest;
pub mod migrations;
pub mod portable;
pub mod multiworld;
pub mod repair;
pub mod schema_check;
+253
View File
@@ -0,0 +1,253 @@
//! Account-wide collections (wardrobe, vanity, mounts, pets, ...): sets of numeric ids that only ever **grow**.
//!
//! * `canonical = canonical U incoming`: an id is never removed because another realm did not return it.
//! * Compact form: sorted ids, delta + LEB128 varint, ~1-2 bytes per id; never a verbose JSON array.
//! * `collection_hash` makes "did anything change?" a 32-byte comparison; when it is equal nothing is transferred.
use sha2::{Digest, Sha256};
use super::error::{PortableError, Result};
use super::versions::PORTABLE_COLLECTION_FORMAT_VERSION;
/// Largest number of ids in one collection (a realm's whole wardrobe is a few thousand).
pub const MAX_SET_IDS: usize = 2_000_000;
/// Largest encoded size accepted when decoding.
pub const MAX_ENCODED_BYTES: usize = 8 * 1024 * 1024;
/// Collection kinds are namespaced names such as `coa:wardrobe`, `coa:vanity`, `mod:foo:mounts`.
pub fn valid_kind(kind: &str) -> bool {
super::ids::valid_namespace(kind) && kind.contains(':')
}
#[derive(Clone, Debug, PartialEq, Eq, Default)]
pub struct IdSet(Vec<u32>);
impl IdSet {
pub fn new() -> Self {
Self(Vec::new())
}
/// Build from any ids: sorted and de-duplicated.
pub fn from_ids(ids: impl IntoIterator<Item = u32>) -> Result<Self> {
let mut v: Vec<u32> = ids.into_iter().collect();
v.sort_unstable();
v.dedup();
if v.len() > MAX_SET_IDS {
return Err(PortableError::LimitExceeded(format!("a collection cannot hold more than {MAX_SET_IDS} ids")));
}
Ok(Self(v))
}
pub fn len(&self) -> usize {
self.0.len()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn contains(&self, id: u32) -> bool {
self.0.binary_search(&id).is_ok()
}
pub fn ids(&self) -> &[u32] {
&self.0
}
/// `self U other`.
pub fn union(&self, other: &IdSet) -> IdSet {
let (a, b) = (&self.0, &other.0);
let mut out = Vec::with_capacity(a.len() + b.len());
let (mut i, mut j) = (0, 0);
while i < a.len() && j < b.len() {
match a[i].cmp(&b[j]) {
std::cmp::Ordering::Less => {
out.push(a[i]);
i += 1;
}
std::cmp::Ordering::Greater => {
out.push(b[j]);
j += 1;
}
std::cmp::Ordering::Equal => {
out.push(a[i]);
i += 1;
j += 1;
}
}
}
out.extend_from_slice(&a[i..]);
out.extend_from_slice(&b[j..]);
IdSet(out)
}
/// How many ids of `other` are not in `self`.
pub fn count_new(&self, other: &IdSet) -> usize {
other.0.iter().filter(|id| !self.contains(**id)).count()
}
/// `[format version][count varint][first id varint][gap varint ...]` where every gap is the difference to the
/// previous id (always at least 1).
pub fn encode(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(2 + self.0.len() * 2);
out.push(PORTABLE_COLLECTION_FORMAT_VERSION as u8);
put_varint(&mut out, self.0.len() as u64);
let mut previous = 0u32;
for (index, id) in self.0.iter().enumerate() {
put_varint(&mut out, if index == 0 { *id as u64 } else { (*id - previous) as u64 });
previous = *id;
}
out
}
pub fn decode(bytes: &[u8]) -> Result<IdSet> {
if bytes.len() > MAX_ENCODED_BYTES {
return Err(PortableError::LimitExceeded("an encoded collection is too large".into()));
}
let bad = |what: &str| PortableError::CorruptSnapshot(format!("collection: {what}"));
let (&version, mut rest) = bytes.split_first().ok_or_else(|| bad("empty"))?;
if version as u32 > PORTABLE_COLLECTION_FORMAT_VERSION {
return Err(PortableError::UnsupportedFormat { found: version as u32, supported: PORTABLE_COLLECTION_FORMAT_VERSION });
}
if version == 0 {
return Err(bad("version 0"));
}
let count = take_varint(&mut rest).ok_or_else(|| bad("truncated count"))?;
if count > MAX_SET_IDS as u64 {
return Err(PortableError::LimitExceeded(format!("a collection cannot hold more than {MAX_SET_IDS} ids")));
}
let mut ids = Vec::with_capacity(count as usize);
let mut previous = 0u64;
for index in 0..count {
let step = take_varint(&mut rest).ok_or_else(|| bad("truncated ids"))?;
let id = if index == 0 {
step
} else {
if step == 0 {
return Err(bad("ids are not strictly ascending"));
}
previous.checked_add(step).ok_or_else(|| bad("overflow"))?
};
if id > u32::MAX as u64 {
return Err(bad("id out of range"));
}
ids.push(id as u32);
previous = id;
}
if !rest.is_empty() {
return Err(bad("trailing bytes"));
}
Ok(IdSet(ids))
}
/// Hash of one collection: binds the kind and the exact content.
pub fn hash(&self, kind: &str) -> [u8; 32] {
let mut h = Sha256::new();
h.update(b"coa-collection-v1\0");
h.update(kind.as_bytes());
h.update([0]);
h.update(self.encode());
h.finalize().into()
}
}
fn put_varint(out: &mut Vec<u8>, mut v: u64) {
while v >= 0x80 {
out.push((v as u8 & 0x7F) | 0x80);
v >>= 7;
}
out.push(v as u8);
}
fn take_varint(rest: &mut &[u8]) -> Option<u64> {
let mut value = 0u64;
for shift in (0..64).step_by(7) {
let (&byte, tail) = rest.split_first()?;
*rest = tail;
value |= ((byte & 0x7F) as u64) << shift;
if byte & 0x80 == 0 {
return Some(value);
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
fn pseudo_random_ids(n: usize, spread: u32) -> Vec<u32> {
// deterministic xorshift, no dependency
let mut x = 0x9E37_79B9u32;
(0..n)
.map(|_| {
x ^= x << 13;
x ^= x >> 17;
x ^= x << 5;
x % spread
})
.collect()
}
#[test]
fn roundtrip_and_size_at_realistic_and_extreme_sizes() {
for (count, spread) in [(0usize, 1u32), (1, 10), (2_000, 40_000), (10_000, 200_000), (50_000, 1_000_000)] {
let set = IdSet::from_ids(pseudo_random_ids(count, spread)).unwrap();
let encoded = set.encode();
assert_eq!(IdSet::decode(&encoded).unwrap(), set);
// a compact binary form: well under 3 bytes per id even for scattered ids, never a JSON array
assert!(encoded.len() <= 8 + set.len() * 3, "{} ids -> {} bytes", set.len(), encoded.len());
}
}
#[test]
fn union_never_removes_and_is_idempotent() {
let a = IdSet::from_ids([5, 1, 9, 9]).unwrap();
let b = IdSet::from_ids([2, 9, 11]).unwrap();
let u = a.union(&b);
assert_eq!(u.ids(), &[1, 2, 5, 9, 11]);
assert!(a.ids().iter().all(|id| u.contains(*id)), "nothing of the old set may disappear");
assert_eq!(u.union(&b), u);
assert_eq!(u.union(&IdSet::new()), u);
assert_eq!(a.count_new(&b), 2);
assert_eq!(u.count_new(&a), 0);
}
#[test]
fn hash_changes_with_content_and_kind() {
let a = IdSet::from_ids([1, 2, 3]).unwrap();
let b = IdSet::from_ids([1, 2, 4]).unwrap();
assert_eq!(a.hash("coa:wardrobe"), IdSet::from_ids([3, 2, 1]).unwrap().hash("coa:wardrobe"));
assert_ne!(a.hash("coa:wardrobe"), b.hash("coa:wardrobe"));
assert_ne!(a.hash("coa:wardrobe"), a.hash("coa:vanity"));
}
#[test]
fn malformed_encodings_are_refused() {
let good = IdSet::from_ids([10, 20, 30]).unwrap().encode();
assert!(IdSet::decode(&[]).is_err());
assert!(IdSet::decode(&good[..good.len() - 1]).is_err(), "truncated");
let mut trailing = good.clone();
trailing.push(0);
assert!(IdSet::decode(&trailing).is_err());
assert!(IdSet::decode(&[1, 3, 10, 0, 5]).is_err(), "a zero gap means a duplicate");
assert!(IdSet::decode(&[9, 0]).is_err(), "newer format");
assert!(IdSet::decode(&[0, 0]).is_err(), "version zero");
// count far beyond the limit
let mut huge = vec![1];
put_varint(&mut huge, MAX_SET_IDS as u64 + 1);
assert!(matches!(IdSet::decode(&huge), Err(PortableError::LimitExceeded(_))));
// an id above u32
let mut big = vec![1, 1];
put_varint(&mut big, u32::MAX as u64 + 1);
assert!(IdSet::decode(&big).is_err());
// an over-long varint must not loop or overflow
assert!(IdSet::decode(&[1, 1, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80, 0x80]).is_err());
}
#[test]
fn kinds_are_namespaced() {
assert!(valid_kind("coa:wardrobe"));
assert!(valid_kind("mod:foo:mounts"));
assert!(!valid_kind("wardrobe"));
assert!(!valid_kind("Coa:Wardrobe"));
assert!(!valid_kind(""));
}
}
+50
View File
@@ -0,0 +1,50 @@
use super::ids::CharacterId;
pub type Result<T> = std::result::Result<T, PortableError>;
#[derive(Debug, thiserror::Error)]
pub enum PortableError {
#[error("database: {0}")]
Sqlite(#[from] rusqlite::Error),
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("json: {0}")]
Json(#[from] serde_json::Error),
#[error("character {0} already exists")]
DuplicateCharacter(CharacterId),
#[error("character {0} does not exist")]
UnknownCharacter(CharacterId),
#[error("revision {revision} of character {character} does not exist (it may have been pruned)")]
UnknownRevision { character: CharacterId, revision: u64 },
#[error("stale revision: the caller expected {expected} but the canonical revision is {current}")]
StaleRevision { expected: u64, current: u64 },
#[error("a character's ruleset cannot change ({from} -> {to})")]
RulesetChange { from: String, to: String },
#[error("the snapshot belongs to character {found}, not {expected}")]
WrongCharacter { expected: CharacterId, found: CharacterId },
#[error("local character {local_guid} on server {server_id} is already bound to another portable character")]
LocalGuidTaken { server_id: String, local_guid: u32 },
#[error("local item {local_item_guid} on server {server_id} is mapped twice for one character")]
ItemGuidConflict { server_id: String, local_item_guid: u32 },
#[error("invalid data: {0}")]
Invalid(String),
#[error("limit exceeded: {0}")]
LimitExceeded(String),
#[error("stored snapshot is damaged: {0}")]
CorruptSnapshot(String),
#[error("data format version {found} is newer than the supported version {supported}")]
UnsupportedFormat { found: u32, supported: u32 },
#[error("the portable database was written by a newer Manager (schema {found}, this Manager understands {supported})")]
NewerDatabase { found: i64, supported: i64 },
#[error("this file is not a Manager portable database")]
NotPortableDatabase,
}
impl From<PortableError> for crate::Error {
fn from(e: PortableError) -> Self {
crate::Error::Invalid(e.to_string())
}
}
+215
View File
@@ -0,0 +1,215 @@
//! Deterministic test characters. Ids are fixed UUIDv7 values so two calls build equal characters.
use std::collections::BTreeMap;
use uuid::Uuid;
use super::ids::{CharacterId, ContentId, PortableItemId, PortablePetId};
use super::model::*;
use super::versions::PORTABLE_CHARACTER_FORMAT_VERSION;
pub fn id7(n: u64) -> Uuid {
let mut random = [0u8; 10];
random[..8].copy_from_slice(&n.to_be_bytes());
uuid::Builder::from_unix_timestamp_millis(1_790_000_000_000 + n, &random).into_uuid()
}
pub fn character_id(n: u64) -> CharacterId {
CharacterId::from_uuid(id7(n)).unwrap()
}
fn item_id(n: u64) -> PortableItemId {
PortableItemId::from_uuid(id7(10_000 + n)).unwrap()
}
fn content(kind: &str, id: u64) -> ContentId {
ContentId::new("coa", kind, id).unwrap()
}
fn empty_progression(level: u8) -> Progression {
Progression {
level,
xp: 0,
money: 0,
honor: Honor { arena_points: 0, total_honor: 0, today_honor: 0, yesterday_honor: 0, total_kills: 0, today_kills: 0, yesterday_kills: 0 },
known_currencies: 0,
chosen_title: 0,
known_titles: "0 0 0 0 0 0".into(),
explored_zones: "0 0 0 0".into(),
taxi_mask: "0 0 0 0".into(),
bank_slots: 0,
stable_slots: 0,
watched_faction: 0,
action_bars_mask: 0,
}
}
fn empty_build() -> Build {
Build {
spells: Vec::new(),
talents: Vec::new(),
skills: Vec::new(),
glyphs: Vec::new(),
talent_groups_count: 1,
active_talent_group: 0,
extra_bonus_talent_count: 0,
reset_talents_cost: 0,
}
}
pub fn naked_level_one() -> PortableCharacter {
PortableCharacter {
format_version: PORTABLE_CHARACTER_FORMAT_VERSION,
character_id: character_id(1),
ruleset: Ruleset::Coa,
content_namespace: "coa".into(),
identity: Identity {
name: "Newbie".into(),
race: content("race", 1),
class: content("class", 12),
gender: 0,
appearance: Appearance { skin: 1, face: 2, hair_style: 3, hair_color: 4, facial_style: 0 },
cosmetic_flags: 0,
},
progression: empty_progression(1),
build: empty_build(),
items: Vec::new(),
quests: Quests { active: Vec::new(), rewarded: Vec::new() },
reputation: Vec::new(),
actions: Vec::new(),
pets: Vec::new(),
settings: BTreeMap::new(),
client_data: BTreeMap::new(),
extensions: BTreeMap::new(),
}
}
fn simple_item(n: u64, container: Option<PortableItemId>, slot: u8, entry: u64, count: u32) -> PortableItem {
PortableItem {
id: item_id(n),
container,
slot,
entry: content("item", entry),
count,
duration: 0,
charges: vec![0, 0, 0, 0, 0],
flags: 1,
enchantments: Vec::new(),
random_property_id: 0,
durability: 100,
played_time: 3600,
text: None,
creator_name: None,
gift: None,
}
}
/// A level 80 character shaped like the real data of the audit: ~1,300 spells, 105 reputations, 23 skills,
/// equipment, four bags with contents, enchants, gems, a pet, CoA settings and macros.
pub fn geared_level_eighty() -> PortableCharacter {
let mut c = naked_level_one();
c.character_id = character_id(2);
c.identity.name = "Geared".into();
c.identity.class = content("class", 21);
c.progression = empty_progression(80);
c.progression.xp = 12_345;
c.progression.money = 200_060_523;
c.progression.honor.total_honor = 4092;
c.progression.honor.arena_points = 150;
c.progression.known_currencies = 0b1010_0101;
c.progression.chosen_title = 5;
c.progression.explored_zones = (0..128).map(|i| (i * 7919u32).to_string()).collect::<Vec<_>>().join(" ");
c.progression.bank_slots = 4;
c.build.spells = (1..=1284u32).map(|n| (500_000 + n * 3, if n % 5 == 0 { 3 } else { 1 })).collect();
c.build.skills = (1..=23u32).map(|n| Skill { skill: n * 11, value: 300 + n as u16, max: 450 }).collect();
c.build.glyphs = vec![GlyphSet { talent_group: 0, glyphs: [1, 2, 3, 4, 5, 6] }];
c.build.extra_bonus_talent_count = 2;
// equipment 0..18
let mut n = 0u64;
let mut equipped_ids = Vec::new();
for slot in 0..19u8 {
n += 1;
let mut item = simple_item(n, None, slot, 30_000 + n * 17, 1);
item.flags = 1;
item.durability = 90 + slot as u32;
if slot % 3 == 0 {
item.enchantments = vec![
Enchantment { slot: 0, id: 3_000 + slot as u32, duration: 0, charges: 0 },
Enchantment { slot: 2, id: 3_878, duration: 0, charges: 0 },
Enchantment { slot: 3, id: 3_879, duration: 0, charges: 0 },
];
}
if slot == 5 {
item.random_property_id = -57;
item.creator_name = Some("Crafter".into());
}
equipped_ids.push(item.id);
c.items.push(item);
}
// four bags (slots 19..22), each with six items
for bag_slot in 19..23u8 {
n += 1;
let bag = simple_item(n, None, bag_slot, 41_000 + bag_slot as u64, 1);
let bag_id = bag.id;
c.items.push(bag);
for slot in 0..6u8 {
n += 1;
c.items.push(simple_item(n, Some(bag_id), slot, 50_000 + n, 1 + (slot as u32 % 3) * 4));
}
}
// backpack
for slot in 23..31u8 {
n += 1;
c.items.push(simple_item(n, None, slot, 60_000 + n, 20));
}
// bank bag with a book that has text, and a currency token
n += 1;
let mut book = simple_item(n, None, 40, 70_001, 1);
book.text = Some("A very short book.\nSecond line.".into());
c.items.push(book);
n += 1;
let mut token = simple_item(n, None, 118, 375_250, 12);
token.flags = 0;
c.items.push(token);
n += 1;
let mut wrapped = simple_item(n, None, 41, 5_042, 1);
wrapped.gift = Some(Gift { entry: content("item", 8_000), flags: 8 });
c.items.push(wrapped);
c.quests.active = vec![
ActiveQuest { quest: 12_001, status: 3, explored: false, timer: 0, mob_counts: [3, 0, 0, 0], item_counts: [0; 6], player_count: 0 },
ActiveQuest { quest: 12_002, status: 1, explored: true, timer: 600, mob_counts: [0; 4], item_counts: [2, 0, 0, 0, 0, 0], player_count: 0 },
];
c.quests.rewarded = vec![12_000, 12_003, 12_004, 99_001];
c.reputation = (1..=105u32).map(|f| ReputationEntry { faction: f * 3, standing: (f as i32) * 100 - 3_000, flags: if f % 7 == 0 { 17 } else { 1 } }).collect();
c.actions = (0..12u8).map(|b| ActionButton { spec: 0, button: b, action: 500_003 + b as u32 * 3, kind: 0 }).collect();
c.pets = vec![PortablePet {
id: PortablePetId::from_uuid(id7(20_000)).unwrap(),
entry: content("creature", 416),
model_id: 4_449,
created_by_spell: 688,
pet_type: 0,
level: 80,
exp: 1_000,
react_state: 1,
name: "Fuzzy".into(),
renamed: true,
slot: 0,
health: 5_000,
mana: 800,
happiness: 0,
action_bar: "7 2 0 0 1 0 1 1 0".into(),
spells: vec![PetSpell { spell: 3110, active: 1 }, PetSpell { spell: 6307, active: 0 }],
declined_names: None,
}];
c.settings.insert("core.ascension_active_spec".into(), vec![54]);
c.settings.insert("core.ascension_starter".into(), vec![1]);
c.settings.insert("core.ascension_build.54".into(), vec![3, 56_710, 56_320, 90_101]);
c.settings.insert("core.ascension_bar.54".into(), vec![2, 0, 500_003, 1, 500_006]);
c.client_data.insert(5, ClientBlob { time: 1_790_000_000, data: Bytes(b"macro data \x00\x01\x02 with binary".to_vec()) });
c.normalized()
}
+229
View File
@@ -0,0 +1,229 @@
//! Identifiers. Everything that must be globally unique is a UUIDv7 (time-ordered, generated locally, never an
//! auto-increment). Numeric ids of game content are never global: they live in a namespace.
use std::fmt;
use std::str::FromStr;
use serde::{de, Deserialize, Deserializer, Serialize, Serializer};
use uuid::Uuid;
use super::error::{PortableError, Result};
macro_rules! uuid7_id {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct $name(Uuid);
impl $name {
/// A new, time-ordered id.
pub fn new() -> Self {
Self(Uuid::now_v7())
}
/// Wrap an existing UUID. Only version-7 ids are accepted: an id from outside this system that is not
/// time-ordered is a sign of a foreign or forged source.
pub fn from_uuid(uuid: Uuid) -> Result<Self> {
if uuid.get_version_num() != 7 || uuid.get_variant() != uuid::Variant::RFC4122 {
return Err(PortableError::Invalid(format!("{} is not a UUIDv7", uuid)));
}
Ok(Self(uuid))
}
pub fn as_uuid(&self) -> Uuid {
self.0
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0.hyphenated())
}
}
impl fmt::Debug for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}({})", stringify!($name), self.0.hyphenated())
}
}
impl FromStr for $name {
type Err = PortableError;
fn from_str(s: &str) -> Result<Self> {
let uuid = Uuid::parse_str(s).map_err(|_| PortableError::Invalid(format!("{s:?} is not a UUID")))?;
Self::from_uuid(uuid)
}
}
impl Serialize for $name {
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let text = String::deserialize(d)?;
text.parse().map_err(de::Error::custom)
}
}
};
}
uuid7_id!(
/// The global identity of a portable character. Unlike the local AzerothCore guid it is the same on every realm.
CharacterId
);
uuid7_id!(
/// The owner's profile (their characters and collections).
ProfileId
);
uuid7_id!(
/// The global identity of one item of a portable character. Local item guids differ per realm; this does not.
PortableItemId
);
uuid7_id!(
/// The global identity of one pet of a portable character.
PortablePetId
);
/// A reference to a piece of game content that cannot be mistaken for another namespace's numbering:
/// `core:wotlk:item:19019`, `coa:wardrobe:10581`, `mod:<module>:item:7`.
///
/// The textual form is `<namespace>:<kind>:<id>` where the namespace may itself contain colons; the last two
/// segments are always the kind and the numeric id.
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ContentId {
namespace: String,
kind: String,
id: u64,
}
const MAX_SEGMENT: usize = 48;
fn valid_segment(s: &str) -> bool {
!s.is_empty() && s.len() <= MAX_SEGMENT && s.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-' || b == b'.')
}
/// A namespace is one or more segments joined by colons.
pub fn valid_namespace(ns: &str) -> bool {
ns.len() <= 96 && ns.split(':').all(valid_segment)
}
impl ContentId {
pub fn new(namespace: &str, kind: &str, id: u64) -> Result<Self> {
if !valid_namespace(namespace) || !valid_segment(kind) {
return Err(PortableError::Invalid(format!("invalid content id {namespace}:{kind}:{id}")));
}
Ok(Self { namespace: namespace.to_string(), kind: kind.to_string(), id })
}
pub fn namespace(&self) -> &str {
&self.namespace
}
pub fn kind(&self) -> &str {
&self.kind
}
pub fn id(&self) -> u64 {
self.id
}
}
impl fmt::Display for ContentId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}:{}:{}", self.namespace, self.kind, self.id)
}
}
impl fmt::Debug for ContentId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "ContentId({self})")
}
}
impl FromStr for ContentId {
type Err = PortableError;
fn from_str(s: &str) -> Result<Self> {
let bad = || PortableError::Invalid(format!("{s:?} is not a content id (namespace:kind:id)"));
let (rest, id) = s.rsplit_once(':').ok_or_else(bad)?;
let (namespace, kind) = rest.rsplit_once(':').ok_or_else(bad)?;
// `u64::from_str` accepts a leading '+', which would make two spellings of one id.
if id.is_empty() || !id.bytes().all(|b| b.is_ascii_digit()) {
return Err(bad());
}
let id: u64 = id.parse().map_err(|_| bad())?;
Self::new(namespace, kind, id)
}
}
impl Serialize for ContentId {
fn serialize<S: Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.collect_str(self)
}
}
impl<'de> Deserialize<'de> for ContentId {
fn deserialize<D: Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let text = String::deserialize(d)?;
text.parse().map_err(de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_are_uuid_v7_and_strictly_time_ordered() {
let ids: Vec<CharacterId> = (0..2000).map(|_| CharacterId::new()).collect();
assert!(ids.iter().all(|id| id.as_uuid().get_version_num() == 7));
assert!(ids.windows(2).all(|w| w[0] < w[1]), "ids generated in sequence must sort in sequence");
let mut unique = ids.clone();
unique.sort();
unique.dedup();
assert_eq!(unique.len(), ids.len());
}
#[test]
fn only_uuid_v7_is_accepted_from_text() {
let good = CharacterId::new();
assert_eq!(good.to_string().parse::<CharacterId>().unwrap(), good);
assert!(Uuid::new_v4().to_string().parse::<CharacterId>().is_err(), "a v4 UUID is not a portable id");
assert!(Uuid::nil().to_string().parse::<CharacterId>().is_err());
assert!("not-a-uuid".parse::<CharacterId>().is_err());
}
#[test]
fn content_ids_keep_their_namespace() {
for text in ["core:wotlk:item:19019", "coa:wardrobe:10581", "mod:craftsmans-codex:item:7", "coa:class:12"] {
let id: ContentId = text.parse().unwrap();
assert_eq!(id.to_string(), text);
}
let id: ContentId = "core:wotlk:item:19019".parse().unwrap();
assert_eq!((id.namespace(), id.kind(), id.id()), ("core:wotlk", "item", 19019));
let other: ContentId = "mod:x:item:19019".parse().unwrap();
assert_ne!(id, other, "the same number in another namespace is a different thing");
}
#[test]
fn malformed_content_ids_are_refused() {
for text in ["", "item:1", "a:b", "a:b:c", "a:b:-1", "a:b:+1", "A:b:1", "a b:c:1", "a::1", ":b:1", "a:b:18446744073709551616"] {
assert!(text.parse::<ContentId>().is_err(), "{text:?} must not parse");
}
}
#[test]
fn ids_serialise_as_plain_strings() {
let id = CharacterId::new();
let json = serde_json::to_string(&id).unwrap();
assert_eq!(json, format!("\"{id}\""));
assert_eq!(serde_json::from_str::<CharacterId>(&json).unwrap(), id);
assert!(serde_json::from_str::<CharacterId>(&format!("\"{}\"", Uuid::new_v4())).is_err());
}
}
@@ -0,0 +1,77 @@
-- Portable store, schema 1. Local to this Manager; realm databases are never touched.
CREATE TABLE profile (
profile_id TEXT PRIMARY KEY,
created_at TEXT NOT NULL,
format_version INTEGER NOT NULL
) STRICT;
CREATE TABLE character (
character_id TEXT PRIMARY KEY,
profile_id TEXT NOT NULL REFERENCES profile(profile_id),
ruleset TEXT NOT NULL CHECK (ruleset IN ('coa', 'wildcard')),
name TEXT NOT NULL,
race TEXT NOT NULL,
class TEXT NOT NULL,
gender INTEGER NOT NULL,
level INTEGER NOT NULL,
revision INTEGER NOT NULL CHECK (revision >= 1),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
archived INTEGER NOT NULL DEFAULT 0 CHECK (archived IN (0, 1))
) STRICT;
CREATE INDEX character_profile ON character(profile_id);
-- One row per kept revision. The newest row is the canonical state (character.revision points at it).
CREATE TABLE snapshot (
character_id TEXT NOT NULL REFERENCES character(character_id) ON DELETE CASCADE,
revision INTEGER NOT NULL CHECK (revision >= 1),
snapshot_format_version INTEGER NOT NULL,
created_at TEXT NOT NULL,
source_server_id TEXT NOT NULL,
content_hash BLOB NOT NULL CHECK (length(content_hash) = 32),
uncompressed_size INTEGER NOT NULL,
payload BLOB NOT NULL,
note TEXT,
PRIMARY KEY (character_id, revision)
) STRICT, WITHOUT ROWID;
-- Which local guid a portable character has on which realm. Internal lookups (checkpoints, imports) go through
-- (server_id, local_guid): a local guid belongs to at most one portable character.
CREATE TABLE character_server_mapping (
character_id TEXT NOT NULL REFERENCES character(character_id) ON DELETE CASCADE,
server_id TEXT NOT NULL,
local_guid INTEGER NOT NULL,
last_revision INTEGER NOT NULL,
state TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (character_id, server_id),
UNIQUE (server_id, local_guid)
) STRICT;
CREATE TABLE item_mapping (
character_id TEXT NOT NULL,
server_id TEXT NOT NULL,
portable_item_id TEXT NOT NULL,
local_item_guid INTEGER NOT NULL,
PRIMARY KEY (character_id, server_id, portable_item_id),
UNIQUE (character_id, server_id, local_item_guid),
FOREIGN KEY (character_id, server_id) REFERENCES character_server_mapping(character_id, server_id) ON DELETE CASCADE
) STRICT;
CREATE TABLE collection (
profile_id TEXT NOT NULL REFERENCES profile(profile_id),
kind TEXT NOT NULL,
collection_revision INTEGER NOT NULL CHECK (collection_revision >= 1),
collection_hash BLOB NOT NULL CHECK (length(collection_hash) = 32),
format_version INTEGER NOT NULL,
item_count INTEGER NOT NULL,
updated_at TEXT NOT NULL,
payload BLOB NOT NULL,
PRIMARY KEY (profile_id, kind)
) STRICT;
CREATE TABLE setting (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
) STRICT;
+24
View File
@@ -0,0 +1,24 @@
//! Portable characters: the local canonical storage of Phase 1.
//!
//! A portable character is owned by this Manager, not by any realm. Realm databases only ever hold temporary working
//! copies. This module contains only the Manager-side model and store; it does not read or write any realm database
//! (that is Phase 2 onwards) and knows nothing about the network.
//!
//! See `docs/PORTABLE_CHARACTERS_AUDIT.md` for the reasoning and `docs/PORTABLE_FORMAT.md` for the format rules.
pub mod collection;
pub mod error;
pub mod ids;
pub mod model;
pub mod snapshot;
pub mod store;
pub mod versions;
#[cfg(test)]
pub(crate) mod fixtures;
pub use collection::IdSet;
pub use error::{PortableError, Result};
pub use ids::{CharacterId, ContentId, PortableItemId, PortablePetId, ProfileId};
pub use model::{PortableCharacter, Ruleset};
pub use store::Store;
+640
View File
@@ -0,0 +1,640 @@
//! The portable character: a *semantic* description of what moves between realms (see
//! `docs/PORTABLE_CHARACTERS_AUDIT.md`, sections 5-7). No database row, guid or position of any realm appears here:
//! local guids live in the Manager's mapping tables, and every item/pet carries a global UUIDv7 instead.
//!
//! Rules:
//! * Unknown fields are refused (`deny_unknown_fields`); a change of shape bumps `format_version`.
//! * Numeric ids of game content other than race/class/item/pet entries (spells, quests, factions, skills, ...) are
//! interpreted in `content_namespace`; race, class and the entries of items/pets carry their own namespace.
//! * Data of modules this Manager does not understand is kept as opaque [`Extension`]s and is never dropped.
//! * Everything is bounded (`limits`), because a snapshot may come from another person's Manager.
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::error::{PortableError, Result};
use super::ids::{valid_namespace, CharacterId, ContentId, PortableItemId, PortablePetId};
use super::versions::PORTABLE_CHARACTER_FORMAT_VERSION;
pub mod limits {
pub const MAX_NAME_CHARS: usize = 25;
pub const MAX_PET_NAME_CHARS: usize = 21;
pub const MAX_ITEMS: usize = 1_500;
pub const MAX_SLOT: u8 = 149;
pub const MAX_ENCHANT_SLOTS: u8 = 12;
pub const MAX_CHARGES: usize = 5;
pub const MAX_ITEM_TEXT_BYTES: usize = 16 * 1024;
pub const MAX_SPELLS: usize = 20_000;
pub const MAX_TALENTS: usize = 4_096;
pub const MAX_SKILLS: usize = 512;
pub const MAX_GLYPH_SETS: usize = 8;
pub const MAX_REPUTATIONS: usize = 2_048;
pub const MAX_ACTIVE_QUESTS: usize = 512;
pub const MAX_REWARDED_QUESTS: usize = 50_000;
pub const MAX_ACTIONS: usize = 1_024;
pub const MAX_PETS: usize = 64;
pub const MAX_PET_SPELLS: usize = 512;
pub const MAX_SETTINGS: usize = 4_096;
pub const MAX_SETTING_VALUES: usize = 4_096;
pub const MAX_SETTING_SOURCE_BYTES: usize = 128;
pub const MAX_CLIENT_BLOBS: usize = 8;
pub const MAX_CLIENT_BLOB_BYTES: usize = 64 * 1024;
pub const MAX_RAW_TEXT_BYTES: usize = 8 * 1024;
pub const MAX_EXTENSIONS: usize = 64;
pub const MAX_EXTENSION_BYTES: usize = 1024 * 1024;
pub const MAX_EXTENSIONS_TOTAL_BYTES: usize = 4 * 1024 * 1024;
/// What the realm itself allows (`MAX_MONEY_AMOUNT`, `Player.h`).
pub const MAX_MONEY: u32 = 0x7FFF_FFFF - 1;
/// Largest decompressed snapshot accepted (decompression-bomb guard).
pub const MAX_SNAPSHOT_BYTES: usize = 16 * 1024 * 1024;
/// Largest compressed snapshot accepted.
pub const MAX_COMPRESSED_BYTES: usize = 8 * 1024 * 1024;
}
use limits::*;
/// A character belongs to exactly one ruleset for its whole life; CoA and Wildcard never mix.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Ruleset {
Coa,
Wildcard,
}
impl Ruleset {
pub fn as_str(self) -> &'static str {
match self {
Ruleset::Coa => "coa",
Ruleset::Wildcard => "wildcard",
}
}
pub fn parse(s: &str) -> Result<Self> {
match s {
"coa" => Ok(Ruleset::Coa),
"wildcard" => Ok(Ruleset::Wildcard),
other => Err(PortableError::Invalid(format!("unknown ruleset {other:?}"))),
}
}
}
impl std::fmt::Display for Ruleset {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
/// Binary data as base64 text in the serialised form.
#[derive(Clone, PartialEq, Eq, Default)]
pub struct Bytes(pub Vec<u8>);
impl std::fmt::Debug for Bytes {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Bytes({} bytes)", self.0.len())
}
}
impl Serialize for Bytes {
fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
use base64::Engine;
s.serialize_str(&base64::engine::general_purpose::STANDARD.encode(&self.0))
}
}
impl<'de> Deserialize<'de> for Bytes {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
use base64::Engine;
let text = String::deserialize(d)?;
base64::engine::general_purpose::STANDARD
.decode(text.as_bytes())
.map(Bytes)
.map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Appearance {
pub skin: u8,
pub face: u8,
pub hair_style: u8,
pub hair_color: u8,
pub facial_style: u8,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Identity {
pub name: String,
pub race: ContentId,
pub class: ContentId,
pub gender: u8,
pub appearance: Appearance,
/// Only the cosmetic bits of `playerFlags` (helm/cloak visibility); never GM/ghost/AFK bits.
pub cosmetic_flags: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Honor {
pub arena_points: u32,
pub total_honor: u32,
pub today_honor: u32,
pub yesterday_honor: u32,
pub total_kills: u32,
pub today_kills: u16,
pub yesterday_kills: u16,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Progression {
pub level: u8,
pub xp: u32,
/// Copper.
pub money: u32,
pub honor: Honor,
pub known_currencies: u64,
pub chosen_title: u32,
/// Raw `knownTitles` text of the realm (space separated integers).
pub known_titles: String,
/// Raw `exploredZones` text.
pub explored_zones: String,
/// Raw `taximask` text.
pub taxi_mask: String,
pub bank_slots: u8,
pub stable_slots: u8,
pub watched_faction: u32,
/// Which action bars are shown (bitmask); not the bars themselves.
pub action_bars_mask: u8,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Skill {
pub skill: u32,
pub value: u16,
pub max: u16,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GlyphSet {
pub talent_group: u8,
pub glyphs: [u16; 6],
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Build {
/// `(spell id, spec mask)`, ascending by spell id. CoA talents are spells, so this is also the talent state.
pub spells: Vec<(u32, u8)>,
/// Stock talents `(spell id, spec mask)`; empty on CoA, kept for stock-shaped rulesets.
pub talents: Vec<(u32, u8)>,
pub skills: Vec<Skill>,
pub glyphs: Vec<GlyphSet>,
pub talent_groups_count: u8,
pub active_talent_group: u8,
pub extra_bonus_talent_count: i32,
pub reset_talents_cost: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Enchantment {
/// 0..12, the realm's enchantment slot.
pub slot: u8,
pub id: u32,
pub duration: u32,
pub charges: u32,
}
/// A wrapped gift: what is inside the wrapper.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Gift {
pub entry: ContentId,
pub flags: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PortableItem {
pub id: PortableItemId,
/// The bag this item is in; `None` = directly on the character (equipment, bag slots, backpack, bank, keyring,
/// currency tokens). A container must itself be directly on the character.
pub container: Option<PortableItemId>,
pub slot: u8,
pub entry: ContentId,
pub count: u32,
pub duration: i32,
/// Spell charges, at most five.
pub charges: Vec<i32>,
pub flags: u32,
/// Only the non-empty slots, ascending by slot.
pub enchantments: Vec<Enchantment>,
/// Negative = suffix.
pub random_property_id: i32,
pub durability: u32,
pub played_time: u32,
pub text: Option<String>,
/// Display name of the crafter; the crafter's guid is never carried (it names a character of another realm).
pub creator_name: Option<String>,
pub gift: Option<Gift>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ActiveQuest {
pub quest: u32,
pub status: u8,
pub explored: bool,
pub timer: u32,
pub mob_counts: [u16; 4],
pub item_counts: [u16; 6],
pub player_count: u16,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Quests {
pub active: Vec<ActiveQuest>,
/// Completed quest ids, ascending. Daily/weekly/monthly/seasonal state is realm-clock state and is not carried.
pub rewarded: Vec<u32>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ReputationEntry {
pub faction: u32,
pub standing: i32,
pub flags: u32,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ActionButton {
pub spec: u8,
pub button: u8,
pub action: u32,
pub kind: u8,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PetSpell {
pub spell: u32,
pub active: u8,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PortablePet {
pub id: PortablePetId,
pub entry: ContentId,
pub model_id: u32,
pub created_by_spell: u32,
/// 0 = summon pet, 1 = hunter pet.
pub pet_type: u8,
pub level: u16,
pub exp: u32,
pub react_state: u8,
pub name: String,
pub renamed: bool,
/// The realm's slot: current / stable 1-4 / not in slot.
pub slot: u8,
pub health: u32,
pub mana: u32,
pub happiness: u32,
pub action_bar: String,
/// Ascending by spell id.
pub spells: Vec<PetSpell>,
/// The five declined-name forms (ruRU) when present.
pub declined_names: Option<[String; 5]>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ClientBlob {
pub time: u32,
pub data: Bytes,
}
/// Data of a module this Manager may not understand. It is stored and returned byte for byte.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Extension {
pub module_version: String,
pub format_version: u32,
/// Lower-case hex SHA-256 of `payload`; a mismatch means the blob was corrupted.
pub content_hash: String,
pub payload: Bytes,
}
impl Extension {
pub fn new(module_version: &str, format_version: u32, payload: Vec<u8>) -> Self {
Self {
module_version: module_version.to_string(),
format_version,
content_hash: hex::encode(Sha256::digest(&payload)),
payload: Bytes(payload),
}
}
pub fn is_intact(&self) -> bool {
self.content_hash == hex::encode(Sha256::digest(&self.payload.0))
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PortableCharacter {
pub format_version: u32,
pub character_id: CharacterId,
pub ruleset: Ruleset,
/// Namespace of the plain numeric ids below (spells, quests, factions, skills, glyphs).
pub content_namespace: String,
pub identity: Identity,
pub progression: Progression,
pub build: Build,
pub items: Vec<PortableItem>,
pub quests: Quests,
pub reputation: Vec<ReputationEntry>,
pub actions: Vec<ActionButton>,
pub pets: Vec<PortablePet>,
/// CoA gameplay state (`character_settings`): source -> integer vector. Only allow-listed sources are carried
/// (policy lives with the exporter).
pub settings: BTreeMap<String, Vec<u32>>,
/// Per-character client blobs by account-data type (macros = 5).
pub client_data: BTreeMap<u8, ClientBlob>,
/// Module data by extension namespace (`mod:<module>`), kept even when unsupported by a realm.
pub extensions: BTreeMap<String, Extension>,
}
fn invalid<T>(msg: impl Into<String>) -> Result<T> {
Err(PortableError::Invalid(msg.into()))
}
fn too_many<T>(what: &str, max: usize) -> Result<T> {
Err(PortableError::LimitExceeded(format!("more than {max} {what}")))
}
fn check_text(what: &str, s: &str, max_bytes: usize) -> Result<()> {
if s.len() > max_bytes {
return Err(PortableError::LimitExceeded(format!("{what} is longer than {max_bytes} bytes")));
}
Ok(())
}
fn check_name(what: &str, s: &str, max_chars: usize) -> Result<()> {
let chars = s.chars().count();
if chars == 0 || chars > max_chars {
return invalid(format!("{what} must have 1 to {max_chars} characters"));
}
if s.chars().any(|c| c.is_control()) || s.trim() != s {
return invalid(format!("{what} contains control characters or surrounding spaces"));
}
Ok(())
}
fn ascending_unique<T: Ord + Copy>(what: &str, ids: impl Iterator<Item = T>) -> Result<()> {
let mut previous: Option<T> = None;
for id in ids {
if previous.is_some_and(|p| p >= id) {
return invalid(format!("{what} must be strictly ascending (call normalize first)"));
}
previous = Some(id);
}
Ok(())
}
impl PortableCharacter {
/// Canonical order: everything that is a set is sorted and de-duplicated, so equal characters serialise to equal
/// bytes (and therefore to equal hashes).
pub fn normalized(mut self) -> Self {
self.normalize();
self
}
pub fn normalize(&mut self) {
fn merge_masks(list: &mut Vec<(u32, u8)>) {
list.sort_by_key(|e| e.0);
let mut out: Vec<(u32, u8)> = Vec::with_capacity(list.len());
for (id, mask) in list.drain(..) {
match out.last_mut() {
Some(last) if last.0 == id => last.1 |= mask,
_ => out.push((id, mask)),
}
}
*list = out;
}
merge_masks(&mut self.build.spells);
merge_masks(&mut self.build.talents);
self.build.skills.sort_by_key(|s| s.skill);
self.build.skills.dedup_by_key(|s| s.skill);
self.build.glyphs.sort_by_key(|g| g.talent_group);
self.build.glyphs.dedup_by_key(|g| g.talent_group);
self.items.sort_by(|a, b| {
let key = |i: &PortableItem| (i.container.is_some(), i.container.map(|c| c.as_uuid()), i.slot, i.id.as_uuid());
key(a).cmp(&key(b))
});
for item in &mut self.items {
item.enchantments.sort_by_key(|e| e.slot);
}
self.quests.active.sort_by_key(|q| q.quest);
self.quests.rewarded.sort_unstable();
self.quests.rewarded.dedup();
self.reputation.sort_by_key(|r| r.faction);
self.actions.sort_by_key(|a| (a.spec, a.button));
self.pets.sort_by_key(|p| (p.slot, p.id.as_uuid()));
for pet in &mut self.pets {
pet.spells.sort_by_key(|s| s.spell);
}
}
/// Structural validation. Run on everything that enters the store or comes from outside.
pub fn validate(&self) -> Result<()> {
if self.format_version != PORTABLE_CHARACTER_FORMAT_VERSION {
return Err(PortableError::UnsupportedFormat { found: self.format_version, supported: PORTABLE_CHARACTER_FORMAT_VERSION });
}
if !valid_namespace(&self.content_namespace) {
return invalid("content_namespace is not a valid namespace");
}
// identity
check_name("character name", &self.identity.name, MAX_NAME_CHARS)?;
if self.identity.gender > 2 {
return invalid("gender must be 0, 1 or 2");
}
// progression
let p = &self.progression;
if p.level == 0 {
return invalid("level must be at least 1");
}
if p.money > MAX_MONEY {
return invalid(format!("money exceeds the realm limit of {MAX_MONEY} copper"));
}
for (what, text) in [("known_titles", &p.known_titles), ("explored_zones", &p.explored_zones), ("taxi_mask", &p.taxi_mask)] {
check_text(what, text, MAX_RAW_TEXT_BYTES)?;
if !text.bytes().all(|b| b.is_ascii_digit() || b == b' ') {
return invalid(format!("{what} may only contain digits and spaces"));
}
}
// build
let b = &self.build;
if b.spells.len() > MAX_SPELLS {
return too_many("spells", MAX_SPELLS);
}
if b.talents.len() > MAX_TALENTS {
return too_many("talents", MAX_TALENTS);
}
if b.skills.len() > MAX_SKILLS {
return too_many("skills", MAX_SKILLS);
}
if b.glyphs.len() > MAX_GLYPH_SETS {
return too_many("glyph sets", MAX_GLYPH_SETS);
}
ascending_unique("spells", b.spells.iter().map(|e| e.0))?;
ascending_unique("talents", b.talents.iter().map(|e| e.0))?;
ascending_unique("skills", b.skills.iter().map(|e| e.skill))?;
ascending_unique("glyph sets", b.glyphs.iter().map(|e| e.talent_group))?;
self.validate_items()?;
// quests, reputation, actions
if self.quests.active.len() > MAX_ACTIVE_QUESTS {
return too_many("active quests", MAX_ACTIVE_QUESTS);
}
if self.quests.rewarded.len() > MAX_REWARDED_QUESTS {
return too_many("completed quests", MAX_REWARDED_QUESTS);
}
ascending_unique("active quests", self.quests.active.iter().map(|q| q.quest))?;
ascending_unique("completed quests", self.quests.rewarded.iter().copied())?;
if self.reputation.len() > MAX_REPUTATIONS {
return too_many("reputations", MAX_REPUTATIONS);
}
ascending_unique("reputations", self.reputation.iter().map(|r| r.faction))?;
if self.actions.len() > MAX_ACTIONS {
return too_many("action buttons", MAX_ACTIONS);
}
ascending_unique("action buttons", self.actions.iter().map(|a| (a.spec, a.button)))?;
// pets
if self.pets.len() > MAX_PETS {
return too_many("pets", MAX_PETS);
}
let mut pet_ids = HashSet::new();
for pet in &self.pets {
if !pet_ids.insert(pet.id) {
return invalid("two pets have the same id");
}
check_name("pet name", &pet.name, MAX_PET_NAME_CHARS)?;
check_text("pet action bar", &pet.action_bar, MAX_RAW_TEXT_BYTES)?;
if pet.spells.len() > MAX_PET_SPELLS {
return too_many("pet spells", MAX_PET_SPELLS);
}
ascending_unique("pet spells", pet.spells.iter().map(|s| s.spell))?;
if let Some(names) = &pet.declined_names {
for n in names {
check_text("pet declined name", n, 128)?;
}
}
}
// settings, client blobs, extensions
if self.settings.len() > MAX_SETTINGS {
return too_many("settings sources", MAX_SETTINGS);
}
for (source, values) in &self.settings {
if source.is_empty() || source.len() > MAX_SETTING_SOURCE_BYTES || !source.bytes().all(|c| c.is_ascii_alphanumeric() || b"._-:".contains(&c)) {
return invalid(format!("invalid settings source {source:?}"));
}
if values.len() > MAX_SETTING_VALUES {
return too_many("values in one settings source", MAX_SETTING_VALUES);
}
}
if self.client_data.len() > MAX_CLIENT_BLOBS {
return too_many("client data blobs", MAX_CLIENT_BLOBS);
}
for blob in self.client_data.values() {
if blob.data.0.len() > MAX_CLIENT_BLOB_BYTES {
return Err(PortableError::LimitExceeded(format!("a client data blob is larger than {MAX_CLIENT_BLOB_BYTES} bytes")));
}
}
if self.extensions.len() > MAX_EXTENSIONS {
return too_many("extensions", MAX_EXTENSIONS);
}
let mut total = 0usize;
for (namespace, ext) in &self.extensions {
if !valid_namespace(namespace) {
return invalid(format!("invalid extension namespace {namespace:?}"));
}
if ext.format_version == 0 || ext.module_version.is_empty() || ext.module_version.len() > 64 {
return invalid(format!("extension {namespace} has an invalid version"));
}
if ext.payload.0.len() > MAX_EXTENSION_BYTES {
return Err(PortableError::LimitExceeded(format!("extension {namespace} is larger than {MAX_EXTENSION_BYTES} bytes")));
}
if !ext.is_intact() {
return Err(PortableError::CorruptSnapshot(format!("extension {namespace} does not match its content hash")));
}
total += ext.payload.0.len();
}
if total > MAX_EXTENSIONS_TOTAL_BYTES {
return Err(PortableError::LimitExceeded(format!("extensions are larger than {MAX_EXTENSIONS_TOTAL_BYTES} bytes together")));
}
Ok(())
}
fn validate_items(&self) -> Result<()> {
if self.items.len() > MAX_ITEMS {
return too_many("items", MAX_ITEMS);
}
let mut by_id: HashMap<PortableItemId, &PortableItem> = HashMap::with_capacity(self.items.len());
for item in &self.items {
if by_id.insert(item.id, item).is_some() {
return invalid("two items have the same id");
}
}
let mut places: BTreeSet<(Option<PortableItemId>, u8)> = BTreeSet::new();
for item in &self.items {
if item.slot > MAX_SLOT {
return invalid(format!("item slot {} is out of range", item.slot));
}
if item.count == 0 {
return invalid("an item stack cannot be empty");
}
if item.charges.len() > MAX_CHARGES {
return too_many("charges on one item", MAX_CHARGES);
}
if let Some(text) = &item.text {
check_text("item text", text, MAX_ITEM_TEXT_BYTES)?;
}
if let Some(name) = &item.creator_name {
check_name("creator name", name, MAX_NAME_CHARS)?;
}
let mut last_slot: Option<u8> = None;
for e in &item.enchantments {
if e.slot >= MAX_ENCHANT_SLOTS || last_slot.is_some_and(|l| l >= e.slot) {
return invalid("enchantment slots must be ascending and below 12");
}
last_slot = Some(e.slot);
}
if let Some(container) = item.container {
match by_id.get(&container) {
None => return invalid("an item is in a container that is not part of the character"),
Some(c) if c.container.is_some() => return invalid("a container must be directly on the character"),
Some(_) => {}
}
}
if !places.insert((item.container, item.slot)) {
return invalid("two items occupy the same place");
}
}
Ok(())
}
}
+230
View File
@@ -0,0 +1,230 @@
//! Snapshot encoding: canonical JSON -> SHA-256 -> zstd.
//!
//! * The **content hash** is the SHA-256 of the canonical (normalised) JSON, i.e. of the data, not of the compressed
//! bytes, so it does not depend on the compressor version.
//! * Decoding is hostile-input safe: compressed and decompressed sizes are capped *while reading*
//! (decompression bombs), the format version is checked before the shape, unknown fields are refused, and the
//! hash and every structural limit are verified.
use std::io::Read;
use sha2::{Digest, Sha256};
use super::error::{PortableError, Result};
use super::model::{limits::*, PortableCharacter};
use super::versions::PORTABLE_CHARACTER_FORMAT_VERSION;
const ZSTD_LEVEL: i32 = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EncodedSnapshot {
pub content_hash: [u8; 32],
pub uncompressed_size: u64,
pub payload: Vec<u8>,
}
pub fn hash_hex(hash: &[u8; 32]) -> String {
hex::encode(hash)
}
/// The canonical JSON of a character (normalised first).
pub fn canonical_json(model: &PortableCharacter) -> Result<Vec<u8>> {
let normalized = model.clone().normalized();
let json = serde_json::to_vec(&normalized)?;
if json.len() > MAX_SNAPSHOT_BYTES {
return Err(PortableError::LimitExceeded(format!("the snapshot is larger than {MAX_SNAPSHOT_BYTES} bytes")));
}
Ok(json)
}
pub fn content_hash(model: &PortableCharacter) -> Result<[u8; 32]> {
Ok(Sha256::digest(canonical_json(model)?).into())
}
/// Validate, then encode.
pub fn encode(model: &PortableCharacter) -> Result<EncodedSnapshot> {
let model = model.clone().normalized();
model.validate()?;
let json = canonical_json(&model)?;
let content_hash: [u8; 32] = Sha256::digest(&json).into();
let payload = zstd::stream::encode_all(json.as_slice(), ZSTD_LEVEL)?;
if payload.len() > MAX_COMPRESSED_BYTES {
return Err(PortableError::LimitExceeded(format!("the compressed snapshot is larger than {MAX_COMPRESSED_BYTES} bytes")));
}
Ok(EncodedSnapshot { content_hash, uncompressed_size: json.len() as u64, payload })
}
/// Decode and fully verify. `expected_hash` is the hash recorded next to the payload.
pub fn decode(payload: &[u8], expected_hash: Option<&[u8; 32]>) -> Result<PortableCharacter> {
if payload.len() > MAX_COMPRESSED_BYTES {
return Err(PortableError::LimitExceeded(format!("the compressed snapshot is larger than {MAX_COMPRESSED_BYTES} bytes")));
}
let decoder = zstd::stream::read::Decoder::new(payload).map_err(|e| PortableError::CorruptSnapshot(format!("not a compressed snapshot: {e}")))?;
let mut json = Vec::new();
// Read one byte more than allowed: if it is there, the payload would have inflated past the cap.
decoder
.take(MAX_SNAPSHOT_BYTES as u64 + 1)
.read_to_end(&mut json)
.map_err(|e| PortableError::CorruptSnapshot(format!("decompression failed: {e}")))?;
if json.len() > MAX_SNAPSHOT_BYTES {
return Err(PortableError::LimitExceeded(format!("the snapshot inflates beyond {MAX_SNAPSHOT_BYTES} bytes")));
}
if let Some(expected) = expected_hash {
let actual: [u8; 32] = Sha256::digest(&json).into();
if &actual != expected {
return Err(PortableError::CorruptSnapshot("content hash mismatch".into()));
}
}
decode_json(&json)
}
/// Decode canonical JSON (already decompressed and, if needed, hash-checked).
pub fn decode_json(json: &[u8]) -> Result<PortableCharacter> {
let value: serde_json::Value = serde_json::from_slice(json)?;
let version = value
.get("format_version")
.and_then(|v| v.as_u64())
.ok_or_else(|| PortableError::CorruptSnapshot("format_version is missing".into()))?;
if version > PORTABLE_CHARACTER_FORMAT_VERSION as u64 {
return Err(PortableError::UnsupportedFormat { found: version.min(u32::MAX as u64) as u32, supported: PORTABLE_CHARACTER_FORMAT_VERSION });
}
let model: PortableCharacter = serde_json::from_value(value)?;
model.validate()?;
Ok(model)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::portable::fixtures;
#[test]
fn roundtrip_is_structurally_equal() {
for model in [fixtures::naked_level_one(), fixtures::geared_level_eighty()] {
let model = model.normalized();
let encoded = encode(&model).unwrap();
let back = decode(&encoded.payload, Some(&encoded.content_hash)).unwrap();
assert_eq!(back, model);
assert_eq!(content_hash(&back).unwrap(), encoded.content_hash);
}
}
#[test]
fn unsorted_input_is_canonicalised_before_hashing() {
let mut shuffled = fixtures::geared_level_eighty();
shuffled.build.spells.reverse();
shuffled.reputation.reverse();
shuffled.items.reverse();
let sorted = fixtures::geared_level_eighty();
assert_eq!(content_hash(&shuffled).unwrap(), content_hash(&sorted).unwrap());
assert_eq!(encode(&shuffled).unwrap().payload, encode(&sorted).unwrap().payload);
}
#[test]
fn hash_is_stable_and_sensitive() {
let a = fixtures::geared_level_eighty();
let mut b = a.clone();
assert_eq!(content_hash(&a).unwrap(), content_hash(&b).unwrap());
b.progression.money += 1;
assert_ne!(content_hash(&a).unwrap(), content_hash(&b).unwrap());
}
#[test]
fn unknown_extension_survives_byte_for_byte() {
let mut model = fixtures::geared_level_eighty();
let payload: Vec<u8> = (0..=255u8).cycle().take(5000).collect();
model.extensions.insert("mod:unknown-module".into(), crate::portable::model::Extension::new("9.9.9", 7, payload.clone()));
let encoded = encode(&model).unwrap();
let back = decode(&encoded.payload, Some(&encoded.content_hash)).unwrap();
let ext = &back.extensions["mod:unknown-module"];
assert_eq!(ext.payload.0, payload);
assert_eq!((ext.module_version.as_str(), ext.format_version), ("9.9.9", 7));
}
#[test]
fn corrupted_extension_is_refused() {
let mut model = fixtures::geared_level_eighty();
let mut ext = crate::portable::model::Extension::new("1", 1, vec![1, 2, 3]);
ext.payload.0[0] ^= 0xFF;
model.extensions.insert("mod:broken".into(), ext);
assert!(matches!(encode(&model), Err(PortableError::CorruptSnapshot(_))));
}
#[test]
fn wrong_hash_is_refused() {
let encoded = encode(&fixtures::geared_level_eighty()).unwrap();
let mut wrong = encoded.content_hash;
wrong[0] ^= 1;
assert!(matches!(decode(&encoded.payload, Some(&wrong)), Err(PortableError::CorruptSnapshot(_))));
}
#[test]
fn garbage_and_truncation_are_refused() {
assert!(decode(b"definitely not zstd", None).is_err());
let encoded = encode(&fixtures::geared_level_eighty()).unwrap();
assert!(decode(&encoded.payload[..encoded.payload.len() / 2], None).is_err());
}
#[test]
fn decompression_bomb_is_stopped_at_the_cap() {
// 20 MiB of spaces compress to a few KB; the decoder must refuse instead of inflating it.
let bomb = zstd::stream::encode_all(vec![b' '; MAX_SNAPSHOT_BYTES + 4 * 1024 * 1024].as_slice(), 19).unwrap();
assert!(bomb.len() < 4096, "the test needs a genuinely small payload, got {}", bomb.len());
assert!(matches!(decode(&bomb, None), Err(PortableError::LimitExceeded(_))));
}
#[test]
fn newer_format_is_refused_before_the_shape_is_checked() {
let mut value = serde_json::to_value(fixtures::geared_level_eighty()).unwrap();
value["format_version"] = serde_json::json!(PORTABLE_CHARACTER_FORMAT_VERSION + 1);
value["a_field_of_the_future"] = serde_json::json!(true);
let json = serde_json::to_vec(&value).unwrap();
assert!(matches!(decode_json(&json), Err(PortableError::UnsupportedFormat { .. })));
}
#[test]
fn unknown_fields_in_the_current_format_are_refused() {
let mut value = serde_json::to_value(fixtures::geared_level_eighty()).unwrap();
value["identity"]["secret_flag"] = serde_json::json!(1);
assert!(decode_json(&serde_json::to_vec(&value).unwrap()).is_err());
}
#[test]
fn oversized_content_is_refused() {
let mut model = fixtures::geared_level_eighty();
model.build.spells = (1..=(MAX_SPELLS as u32 + 1)).map(|s| (s, 1)).collect();
assert!(matches!(encode(&model), Err(PortableError::LimitExceeded(_))));
let mut model = fixtures::geared_level_eighty();
model.items[0].text = Some("x".repeat(MAX_ITEM_TEXT_BYTES + 1));
assert!(matches!(encode(&model), Err(PortableError::LimitExceeded(_))));
let mut model = fixtures::geared_level_eighty();
model.progression.money = MAX_MONEY + 1;
assert!(encode(&model).is_err());
}
#[test]
fn broken_item_structure_is_refused() {
// an item inside a container that does not exist
let mut model = fixtures::geared_level_eighty();
let orphan = model.items.iter().position(|i| i.container.is_some()).unwrap();
model.items[orphan].container = Some(crate::portable::ids::PortableItemId::new());
assert!(encode(&model).is_err());
// two items in the same place
let mut model = fixtures::geared_level_eighty();
let mut copy = model.items[0].clone();
copy.id = crate::portable::ids::PortableItemId::new();
model.items.push(copy);
assert!(encode(&model).is_err());
// duplicate item ids
let mut model = fixtures::geared_level_eighty();
let dup = model.items[0].clone();
model.items.push(dup);
assert!(encode(&model).is_err());
}
#[test]
fn a_geared_snapshot_is_small() {
let encoded = encode(&fixtures::geared_level_eighty()).unwrap();
assert!(encoded.payload.len() < 64 * 1024, "{} bytes", encoded.payload.len());
}
}
+672
View File
@@ -0,0 +1,672 @@
//! The local canonical store (SQLite, WAL). This is the **source of truth** for portable characters: realms only
//! hold working copies. Every write is one transaction; every mutation of a character is checked against the revision
//! the caller saw (`StaleRevision`), so a stale writer can never overwrite newer state.
//!
//! Nothing here touches a realm database.
use std::path::{Path, PathBuf};
use rusqlite::{params, Connection, OptionalExtension, Transaction, TransactionBehavior};
use super::collection::{valid_kind, IdSet};
use super::error::{PortableError, Result};
use super::ids::{CharacterId, PortableItemId, ProfileId};
use super::model::{PortableCharacter, Ruleset};
use super::snapshot::{self, EncodedSnapshot};
use super::versions::{PORTABLE_COLLECTION_FORMAT_VERSION, SNAPSHOT_FORMAT_VERSION};
pub const DATABASE_FILE: &str = "portable.db";
/// `PRAGMA application_id`: "COAP". Refuses to adopt an unrelated SQLite file.
const APPLICATION_ID: i64 = 0x434F_4150;
const MIGRATIONS: &[&str] = &[include_str!("migrations/001_init.sql")];
pub const DEFAULT_HISTORY_KEEP: u32 = 20;
pub const MAX_HISTORY_KEEP: u32 = 1_000;
pub struct Store {
conn: Connection,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CharacterRecord {
pub character_id: CharacterId,
pub profile_id: ProfileId,
pub ruleset: Ruleset,
pub name: String,
pub race: String,
pub class: String,
pub gender: u8,
pub level: u8,
pub revision: u64,
pub created_at: String,
pub updated_at: String,
pub archived: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RevisionInfo {
pub revision: u64,
pub created_at: String,
pub source_server_id: String,
pub content_hash: [u8; 32],
pub uncompressed_size: u64,
pub stored_size: u64,
pub note: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MappingState {
/// Bound, nothing imported yet.
Pending,
/// The character is (or may be) in play on that realm.
Active,
/// The realm's copy equals `last_revision`.
Synced,
/// The realm's copy is older than canonical.
Stale,
}
impl MappingState {
pub fn as_str(self) -> &'static str {
match self {
MappingState::Pending => "pending",
MappingState::Active => "active",
MappingState::Synced => "synced",
MappingState::Stale => "stale",
}
}
fn parse(s: &str) -> Result<Self> {
Ok(match s {
"pending" => MappingState::Pending,
"active" => MappingState::Active,
"synced" => MappingState::Synced,
"stale" => MappingState::Stale,
other => return Err(PortableError::Invalid(format!("unknown mapping state {other:?}"))),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MappingRecord {
pub character_id: CharacterId,
pub server_id: String,
pub local_guid: u32,
pub last_revision: u64,
pub state: MappingState,
pub updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CollectionInfo {
pub kind: String,
pub revision: u64,
pub hash: [u8; 32],
pub count: usize,
pub updated_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CollectionMerge {
/// `false` when the incoming set added nothing: no revision was created.
pub changed: bool,
pub added: usize,
/// `None` only when nothing is stored and nothing was added.
pub info: Option<CollectionInfo>,
}
fn now() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}
pub fn valid_server_id(id: &str) -> bool {
!id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric() || b"-_.:".contains(&b))
}
fn check_server_id(id: &str) -> Result<()> {
if valid_server_id(id) {
Ok(())
} else {
Err(PortableError::Invalid(format!("invalid server id {id:?}")))
}
}
fn hash_from_blob(blob: Vec<u8>) -> Result<[u8; 32]> {
blob.try_into().map_err(|_| PortableError::CorruptSnapshot("a stored hash is not 32 bytes".into()))
}
impl Store {
/// Open (creating it if necessary) `<dir>/portable.db`.
pub fn open(dir: &Path) -> Result<Store> {
std::fs::create_dir_all(dir)?;
Store::open_file(&dir.join(DATABASE_FILE))
}
pub fn open_file(file: &Path) -> Result<Store> {
let conn = Connection::open(file)?;
// WAL keeps readers out of writers' way and survives a crash mid-write; FULL sync keeps the canonical state
// durable even on power loss.
conn.pragma_update_and_check(None, "journal_mode", "WAL", |r| r.get::<_, String>(0))?;
conn.pragma_update(None, "synchronous", "FULL")?;
Store::init(conn)
}
pub fn open_in_memory() -> Result<Store> {
Store::init(Connection::open_in_memory()?)
}
fn init(conn: Connection) -> Result<Store> {
conn.pragma_update(None, "foreign_keys", true)?;
conn.busy_timeout(std::time::Duration::from_secs(5))?;
let mut store = Store { conn };
store.migrate()?;
Ok(store)
}
fn migrate(&mut self) -> Result<()> {
let version: i64 = self.conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
let application: i64 = self.conn.pragma_query_value(None, "application_id", |r| r.get(0))?;
let supported = MIGRATIONS.len() as i64;
if application != APPLICATION_ID {
let objects: i64 = self.conn.query_row("SELECT count(*) FROM sqlite_master", [], |r| r.get(0))?;
if application != 0 || version != 0 || objects != 0 {
return Err(PortableError::NotPortableDatabase);
}
}
if version > supported {
return Err(PortableError::NewerDatabase { found: version, supported });
}
for next in version..supported {
let tx = self.conn.transaction()?;
tx.execute_batch(MIGRATIONS[next as usize])?;
tx.pragma_update(None, "application_id", APPLICATION_ID)?;
tx.pragma_update(None, "user_version", next + 1)?;
tx.commit()?;
}
Ok(())
}
pub fn schema_version(&self) -> Result<i64> {
Ok(self.conn.pragma_query_value(None, "user_version", |r| r.get(0))?)
}
fn write_tx(&mut self) -> Result<Transaction<'_>> {
Ok(self.conn.transaction_with_behavior(TransactionBehavior::Immediate)?)
}
// ---- settings -------------------------------------------------------------------------------------------
/// How many revisions of one character are kept (the newest ones).
pub fn history_keep(&self) -> Result<u32> {
let value: Option<String> = self.conn.query_row("SELECT value FROM setting WHERE key = 'history_keep'", [], |r| r.get(0)).optional()?;
Ok(value.and_then(|v| v.parse().ok()).filter(|n| (1..=MAX_HISTORY_KEEP).contains(n)).unwrap_or(DEFAULT_HISTORY_KEEP))
}
pub fn set_history_keep(&mut self, keep: u32) -> Result<()> {
if !(1..=MAX_HISTORY_KEEP).contains(&keep) {
return Err(PortableError::Invalid(format!("history_keep must be between 1 and {MAX_HISTORY_KEEP}")));
}
self.conn.execute("INSERT INTO setting(key, value) VALUES ('history_keep', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", [keep.to_string()])?;
Ok(())
}
// ---- profiles -------------------------------------------------------------------------------------------
pub fn create_profile(&mut self) -> Result<ProfileId> {
let id = ProfileId::new();
self.conn.execute(
"INSERT INTO profile(profile_id, created_at, format_version) VALUES (?1, ?2, ?3)",
params![id.to_string(), now(), super::versions::PORTABLE_CHARACTER_FORMAT_VERSION],
)?;
Ok(id)
}
/// The profile of this Manager: the oldest one, created on first use.
pub fn default_profile(&mut self) -> Result<ProfileId> {
let tx = self.write_tx()?;
let existing: Option<String> = tx.query_row("SELECT profile_id FROM profile ORDER BY profile_id LIMIT 1", [], |r| r.get(0)).optional()?;
let id = match existing {
Some(text) => text.parse()?,
None => {
let id = ProfileId::new();
tx.execute(
"INSERT INTO profile(profile_id, created_at, format_version) VALUES (?1, ?2, ?3)",
params![id.to_string(), now(), super::versions::PORTABLE_CHARACTER_FORMAT_VERSION],
)?;
id
}
};
tx.commit()?;
Ok(id)
}
// ---- characters -----------------------------------------------------------------------------------------
/// Create a character with a **new** UUIDv7 (the one in `model.character_id` is replaced). Revision 1.
pub fn create_character(&mut self, profile: ProfileId, mut model: PortableCharacter, source_server_id: &str) -> Result<CharacterId> {
model.character_id = CharacterId::new();
self.create_character_with_id(profile, model, source_server_id)
}
/// Create a character that already has its identity (restoring or receiving an existing portable character).
/// An id that exists is refused and nothing is written.
pub fn create_character_with_id(&mut self, profile: ProfileId, model: PortableCharacter, source_server_id: &str) -> Result<CharacterId> {
check_server_id(source_server_id)?;
let encoded = snapshot::encode(&model)?;
let id = model.character_id;
let tx = self.write_tx()?;
let exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM character WHERE character_id = ?1)", [id.to_string()], |r| r.get(0))?;
if exists {
return Err(PortableError::DuplicateCharacter(id));
}
let profile_exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM profile WHERE profile_id = ?1)", [profile.to_string()], |r| r.get(0))?;
if !profile_exists {
return Err(PortableError::Invalid(format!("profile {profile} does not exist")));
}
let at = now();
tx.execute(
"INSERT INTO character(character_id, profile_id, ruleset, name, race, class, gender, level, revision, created_at, updated_at, archived)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1, ?9, ?9, 0)",
params![
id.to_string(),
profile.to_string(),
model.ruleset.as_str(),
model.identity.name,
model.identity.race.to_string(),
model.identity.class.to_string(),
model.identity.gender,
model.progression.level,
at
],
)?;
insert_snapshot(&tx, id, 1, &encoded, source_server_id, Some("created"), &at)?;
tx.commit()?;
Ok(id)
}
pub fn character(&self, id: CharacterId) -> Result<CharacterRecord> {
read_character(&self.conn, id)
}
pub fn list_characters(&self, profile: ProfileId) -> Result<Vec<CharacterRecord>> {
let mut stmt = self.conn.prepare("SELECT character_id FROM character WHERE profile_id = ?1 ORDER BY character_id")?;
let ids: Vec<String> = stmt.query_map([profile.to_string()], |r| r.get(0))?.collect::<std::result::Result<_, _>>()?;
ids.iter().map(|text| read_character(&self.conn, text.parse()?)).collect()
}
pub fn set_archived(&mut self, id: CharacterId, archived: bool) -> Result<()> {
let changed = self.conn.execute("UPDATE character SET archived = ?2 WHERE character_id = ?1", params![id.to_string(), archived])?;
if changed == 0 {
return Err(PortableError::UnknownCharacter(id));
}
Ok(())
}
/// Store the next canonical revision. `expected_revision` is the revision the caller based its change on; if the
/// canonical revision has moved on, nothing is written and `StaleRevision` is returned. Returns the new revision.
pub fn commit_snapshot(&mut self, id: CharacterId, expected_revision: u64, model: PortableCharacter, source_server_id: &str, note: Option<&str>) -> Result<u64> {
check_server_id(source_server_id)?;
if model.character_id != id {
return Err(PortableError::WrongCharacter { expected: id, found: model.character_id });
}
let encoded = snapshot::encode(&model)?;
let keep = self.history_keep()?;
let tx = self.write_tx()?;
let current = read_character(&tx, id)?;
if current.ruleset != model.ruleset {
return Err(PortableError::RulesetChange { from: current.ruleset.to_string(), to: model.ruleset.to_string() });
}
if current.revision != expected_revision {
return Err(PortableError::StaleRevision { expected: expected_revision, current: current.revision });
}
let revision = current.revision + 1;
let at = now();
insert_snapshot(&tx, id, revision, &encoded, source_server_id, note, &at)?;
update_character(&tx, id, &model, revision, &at)?;
prune_tx(&tx, id, revision, keep)?;
tx.commit()?;
Ok(revision)
}
/// Make an older revision current **as a new revision** (revision numbers never go down, so freshness stays a
/// plain integer comparison). Returns the new revision.
pub fn rollback_to(&mut self, id: CharacterId, expected_revision: u64, target_revision: u64, source_server_id: &str) -> Result<u64> {
check_server_id(source_server_id)?;
let keep = self.history_keep()?;
let tx = self.write_tx()?;
let current = read_character(&tx, id)?;
if current.revision != expected_revision {
return Err(PortableError::StaleRevision { expected: expected_revision, current: current.revision });
}
let (model, encoded) = read_snapshot_row(&tx, id, target_revision)?;
let revision = current.revision + 1;
let at = now();
insert_snapshot(&tx, id, revision, &encoded, source_server_id, Some(&format!("rollback to revision {target_revision}")), &at)?;
update_character(&tx, id, &model, revision, &at)?;
prune_tx(&tx, id, revision, keep)?;
tx.commit()?;
Ok(revision)
}
pub fn list_revisions(&self, id: CharacterId) -> Result<Vec<RevisionInfo>> {
read_character(&self.conn, id)?;
let mut stmt = self.conn.prepare(
"SELECT revision, created_at, source_server_id, content_hash, uncompressed_size, length(payload), note
FROM snapshot WHERE character_id = ?1 ORDER BY revision",
)?;
let rows = stmt.query_map([id.to_string()], |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, Vec<u8>>(3)?, r.get::<_, i64>(4)?, r.get::<_, i64>(5)?, r.get::<_, Option<String>>(6)?))
})?;
let mut out = Vec::new();
for row in rows {
let (revision, created_at, source_server_id, hash, uncompressed, stored, note) = row?;
out.push(RevisionInfo {
revision: revision as u64,
created_at,
source_server_id,
content_hash: hash_from_blob(hash)?,
uncompressed_size: uncompressed as u64,
stored_size: stored as u64,
note,
});
}
Ok(out)
}
/// One stored revision, verified (hash, limits, structure).
pub fn load_snapshot(&self, id: CharacterId, revision: u64) -> Result<PortableCharacter> {
Ok(read_snapshot_row(&self.conn, id, revision)?.0)
}
pub fn load_current(&self, id: CharacterId) -> Result<PortableCharacter> {
let record = read_character(&self.conn, id)?;
self.load_snapshot(id, record.revision)
}
/// Remove revisions beyond the history limit. The current revision is never removed. Returns how many went.
pub fn prune(&mut self, id: CharacterId) -> Result<usize> {
let keep = self.history_keep()?;
let tx = self.write_tx()?;
let current = read_character(&tx, id)?;
let removed = prune_tx(&tx, id, current.revision, keep)?;
tx.commit()?;
Ok(removed)
}
// ---- realm mappings -------------------------------------------------------------------------------------
/// Record (or update) the local guid of a portable character on a realm. A local guid can belong to one portable
/// character only. Changing the local guid of an existing binding drops that binding's item mappings (they named
/// items of the previous local character).
pub fn bind_server(&mut self, id: CharacterId, server_id: &str, local_guid: u32, last_revision: u64, state: MappingState) -> Result<()> {
check_server_id(server_id)?;
let tx = self.write_tx()?;
let record = read_character(&tx, id)?;
if last_revision > record.revision {
return Err(PortableError::Invalid(format!("revision {last_revision} is newer than the canonical revision {}", record.revision)));
}
let holder: Option<String> = tx
.query_row("SELECT character_id FROM character_server_mapping WHERE server_id = ?1 AND local_guid = ?2", params![server_id, local_guid], |r| r.get(0))
.optional()?;
if holder.is_some_and(|h| h != id.to_string()) {
return Err(PortableError::LocalGuidTaken { server_id: server_id.to_string(), local_guid });
}
let previous: Option<i64> = tx
.query_row("SELECT local_guid FROM character_server_mapping WHERE character_id = ?1 AND server_id = ?2", params![id.to_string(), server_id], |r| r.get(0))
.optional()?;
if previous.is_some_and(|p| p != local_guid as i64) {
tx.execute("DELETE FROM item_mapping WHERE character_id = ?1 AND server_id = ?2", params![id.to_string(), server_id])?;
}
tx.execute(
"INSERT INTO character_server_mapping(character_id, server_id, local_guid, last_revision, state, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
ON CONFLICT(character_id, server_id) DO UPDATE SET local_guid = excluded.local_guid, last_revision = excluded.last_revision,
state = excluded.state, updated_at = excluded.updated_at",
params![id.to_string(), server_id, local_guid, last_revision as i64, state.as_str(), now()],
)?;
tx.commit()?;
Ok(())
}
/// The portable character a realm's local character belongs to. Checkpoints arrive keyed by the local guid.
pub fn find_by_local(&self, server_id: &str, local_guid: u32) -> Result<Option<CharacterId>> {
let found: Option<String> = self
.conn
.query_row("SELECT character_id FROM character_server_mapping WHERE server_id = ?1 AND local_guid = ?2", params![server_id, local_guid], |r| r.get(0))
.optional()?;
found.map(|text| text.parse()).transpose()
}
pub fn server_mappings(&self, id: CharacterId) -> Result<Vec<MappingRecord>> {
let mut stmt = self.conn.prepare(
"SELECT server_id, local_guid, last_revision, state, updated_at FROM character_server_mapping WHERE character_id = ?1 ORDER BY server_id",
)?;
let rows = stmt.query_map([id.to_string()], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?, r.get::<_, i64>(2)?, r.get::<_, String>(3)?, r.get::<_, String>(4)?)))?;
let mut out = Vec::new();
for row in rows {
let (server_id, guid, revision, state, updated_at) = row?;
out.push(MappingRecord { character_id: id, server_id, local_guid: guid as u32, last_revision: revision as u64, state: MappingState::parse(&state)?, updated_at });
}
Ok(out)
}
/// Replace the item mappings of one (character, realm) pair. Every portable item id and every local item guid may
/// appear once. The pair must already be bound with [`Store::bind_server`].
pub fn set_item_mappings(&mut self, id: CharacterId, server_id: &str, mappings: &[(PortableItemId, u32)]) -> Result<()> {
check_server_id(server_id)?;
let mut portable = std::collections::HashSet::new();
let mut local = std::collections::HashSet::new();
for (item, guid) in mappings {
if !portable.insert(*item) {
return Err(PortableError::Invalid(format!("portable item {item} is mapped twice")));
}
if !local.insert(*guid) {
return Err(PortableError::ItemGuidConflict { server_id: server_id.to_string(), local_item_guid: *guid });
}
}
let tx = self.write_tx()?;
let bound: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM character_server_mapping WHERE character_id = ?1 AND server_id = ?2)",
params![id.to_string(), server_id],
|r| r.get(0),
)?;
if !bound {
return Err(PortableError::Invalid(format!("character {id} is not bound to server {server_id}")));
}
tx.execute("DELETE FROM item_mapping WHERE character_id = ?1 AND server_id = ?2", params![id.to_string(), server_id])?;
{
let mut insert = tx.prepare("INSERT INTO item_mapping(character_id, server_id, portable_item_id, local_item_guid) VALUES (?1, ?2, ?3, ?4)")?;
for (item, guid) in mappings {
insert.execute(params![id.to_string(), server_id, item.to_string(), guid])?;
}
}
tx.commit()?;
Ok(())
}
pub fn item_mappings(&self, id: CharacterId, server_id: &str) -> Result<Vec<(PortableItemId, u32)>> {
let mut stmt = self.conn.prepare("SELECT portable_item_id, local_item_guid FROM item_mapping WHERE character_id = ?1 AND server_id = ?2 ORDER BY local_item_guid")?;
let rows = stmt.query_map(params![id.to_string(), server_id], |r| Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)))?;
let mut out = Vec::new();
for row in rows {
let (item, guid) = row?;
out.push((item.parse()?, guid as u32));
}
Ok(out)
}
// ---- collections ----------------------------------------------------------------------------------------
/// `stored = stored U incoming`. Nothing is ever removed. When the union equals what is stored, no revision is
/// created and the hash stays the same.
pub fn merge_collection(&mut self, profile: ProfileId, kind: &str, incoming: &IdSet) -> Result<CollectionMerge> {
if !valid_kind(kind) {
return Err(PortableError::Invalid(format!("invalid collection kind {kind:?}")));
}
let tx = self.write_tx()?;
let profile_exists: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM profile WHERE profile_id = ?1)", [profile.to_string()], |r| r.get(0))?;
if !profile_exists {
return Err(PortableError::Invalid(format!("profile {profile} does not exist")));
}
let stored = read_collection(&tx, profile, kind)?;
let (current_set, revision) = match &stored {
Some((info, set)) => (set.clone(), info.revision),
None => (IdSet::new(), 0),
};
let added = current_set.count_new(incoming);
if added == 0 {
tx.commit()?;
return Ok(CollectionMerge { changed: false, added: 0, info: stored.map(|(info, _)| info) });
}
let merged = current_set.union(incoming);
let hash = merged.hash(kind);
let payload = merged.encode();
let at = now();
tx.execute(
"INSERT INTO collection(profile_id, kind, collection_revision, collection_hash, format_version, item_count, updated_at, payload)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
ON CONFLICT(profile_id, kind) DO UPDATE SET collection_revision = excluded.collection_revision, collection_hash = excluded.collection_hash,
format_version = excluded.format_version, item_count = excluded.item_count, updated_at = excluded.updated_at, payload = excluded.payload",
params![profile.to_string(), kind, (revision + 1) as i64, hash.as_slice(), PORTABLE_COLLECTION_FORMAT_VERSION, merged.len() as i64, at, payload],
)?;
tx.commit()?;
Ok(CollectionMerge {
changed: true,
added,
info: Some(CollectionInfo { kind: kind.to_string(), revision: revision + 1, hash, count: merged.len(), updated_at: at }),
})
}
/// Revision, hash and size without loading the set: the "did it change?" check.
pub fn collection_info(&self, profile: ProfileId, kind: &str) -> Result<Option<CollectionInfo>> {
let row = self
.conn
.query_row(
"SELECT collection_revision, collection_hash, item_count, updated_at FROM collection WHERE profile_id = ?1 AND kind = ?2",
params![profile.to_string(), kind],
|r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?, r.get::<_, i64>(2)?, r.get::<_, String>(3)?)),
)
.optional()?;
row.map(|(revision, hash, count, updated_at)| Ok(CollectionInfo { kind: kind.to_string(), revision: revision as u64, hash: hash_from_blob(hash)?, count: count as usize, updated_at }))
.transpose()
}
pub fn collection(&self, profile: ProfileId, kind: &str) -> Result<Option<(CollectionInfo, IdSet)>> {
read_collection(&self.conn, profile, kind)
}
pub fn path_of(dir: &Path) -> PathBuf {
dir.join(DATABASE_FILE)
}
}
fn read_collection(conn: &Connection, profile: ProfileId, kind: &str) -> Result<Option<(CollectionInfo, IdSet)>> {
let row = conn
.query_row(
"SELECT collection_revision, collection_hash, item_count, updated_at, payload FROM collection WHERE profile_id = ?1 AND kind = ?2",
params![profile.to_string(), kind],
|r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?, r.get::<_, i64>(2)?, r.get::<_, String>(3)?, r.get::<_, Vec<u8>>(4)?)),
)
.optional()?;
let Some((revision, hash, count, updated_at, payload)) = row else { return Ok(None) };
let set = IdSet::decode(&payload)?;
let hash = hash_from_blob(hash)?;
if set.hash(kind) != hash || set.len() != count as usize {
return Err(PortableError::CorruptSnapshot(format!("collection {kind} does not match its recorded hash")));
}
Ok(Some((CollectionInfo { kind: kind.to_string(), revision: revision as u64, hash, count: set.len(), updated_at }, set)))
}
fn read_character(conn: &Connection, id: CharacterId) -> Result<CharacterRecord> {
let row = conn
.query_row(
"SELECT profile_id, ruleset, name, race, class, gender, level, revision, created_at, updated_at, archived FROM character WHERE character_id = ?1",
[id.to_string()],
|r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, String>(3)?,
r.get::<_, String>(4)?,
r.get::<_, u8>(5)?,
r.get::<_, u8>(6)?,
r.get::<_, i64>(7)?,
r.get::<_, String>(8)?,
r.get::<_, String>(9)?,
r.get::<_, bool>(10)?,
))
},
)
.optional()?;
let Some((profile, ruleset, name, race, class, gender, level, revision, created_at, updated_at, archived)) = row else {
return Err(PortableError::UnknownCharacter(id));
};
Ok(CharacterRecord {
character_id: id,
profile_id: profile.parse()?,
ruleset: Ruleset::parse(&ruleset)?,
name,
race,
class,
gender,
level,
revision: revision as u64,
created_at,
updated_at,
archived,
})
}
fn insert_snapshot(tx: &Transaction<'_>, id: CharacterId, revision: u64, encoded: &EncodedSnapshot, source_server_id: &str, note: Option<&str>, at: &str) -> Result<()> {
tx.execute(
"INSERT INTO snapshot(character_id, revision, snapshot_format_version, created_at, source_server_id, content_hash, uncompressed_size, payload, note)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![id.to_string(), revision as i64, SNAPSHOT_FORMAT_VERSION, at, source_server_id, encoded.content_hash.as_slice(), encoded.uncompressed_size as i64, encoded.payload, note],
)?;
Ok(())
}
fn update_character(tx: &Transaction<'_>, id: CharacterId, model: &PortableCharacter, revision: u64, at: &str) -> Result<()> {
tx.execute(
"UPDATE character SET name = ?2, race = ?3, class = ?4, gender = ?5, level = ?6, revision = ?7, updated_at = ?8 WHERE character_id = ?1",
params![id.to_string(), model.identity.name, model.identity.race.to_string(), model.identity.class.to_string(), model.identity.gender, model.progression.level, revision as i64, at],
)?;
Ok(())
}
/// Delete everything older than the newest `keep` revisions; `current` is always among the newest.
fn prune_tx(tx: &Transaction<'_>, id: CharacterId, current: u64, keep: u32) -> Result<usize> {
let cutoff = current as i64 - keep as i64;
if cutoff < 1 {
return Ok(0);
}
Ok(tx.execute("DELETE FROM snapshot WHERE character_id = ?1 AND revision <= ?2 AND revision < ?3", params![id.to_string(), cutoff, current as i64])?)
}
/// Read one revision and verify it against its recorded hash. Also returns the re-encoded form so a rollback can store
/// exactly the same payload.
fn read_snapshot_row(conn: &Connection, id: CharacterId, revision: u64) -> Result<(PortableCharacter, EncodedSnapshot)> {
let row = conn
.query_row(
"SELECT snapshot_format_version, content_hash, uncompressed_size, payload FROM snapshot WHERE character_id = ?1 AND revision = ?2",
params![id.to_string(), revision as i64],
|r| Ok((r.get::<_, u32>(0)?, r.get::<_, Vec<u8>>(1)?, r.get::<_, i64>(2)?, r.get::<_, Vec<u8>>(3)?)),
)
.optional()?;
let Some((format, hash, size, payload)) = row else {
read_character(conn, id)?;
return Err(PortableError::UnknownRevision { character: id, revision });
};
if format > SNAPSHOT_FORMAT_VERSION {
return Err(PortableError::UnsupportedFormat { found: format, supported: SNAPSHOT_FORMAT_VERSION });
}
let hash = hash_from_blob(hash)?;
let model = snapshot::decode(&payload, Some(&hash))?;
if model.character_id != id {
return Err(PortableError::CorruptSnapshot("a stored snapshot names another character".into()));
}
Ok((model, EncodedSnapshot { content_hash: hash, uncompressed_size: size as u64, payload }))
}
#[cfg(test)]
mod tests;
+455
View File
@@ -0,0 +1,455 @@
use super::*;
use crate::portable::fixtures::{self, character_id};
fn fresh() -> (Store, ProfileId) {
let mut store = Store::open_in_memory().unwrap();
let profile = store.default_profile().unwrap();
(store, profile)
}
fn created(store: &mut Store, profile: ProfileId) -> CharacterId {
store.create_character(profile, fixtures::geared_level_eighty(), "realm-a").unwrap()
}
fn with_money(mut model: PortableCharacter, money: u32) -> PortableCharacter {
model.progression.money = money;
model
}
#[test]
fn create_character_identity() {
let (mut store, profile) = fresh();
let model = fixtures::geared_level_eighty();
let id = store.create_character(profile, model.clone(), "realm-a").unwrap();
// a fresh UUIDv7, not the placeholder carried by the model
assert_ne!(id, model.character_id);
assert_eq!(id.as_uuid().get_version_num(), 7);
let record = store.character(id).unwrap();
assert_eq!(record.revision, 1);
assert_eq!((record.name.as_str(), record.level, record.ruleset), ("Geared", 80, Ruleset::Coa));
assert_eq!(record.race, "coa:race:1");
assert_eq!(record.class, "coa:class:21");
assert_eq!(record.profile_id, profile);
assert!(!record.archived);
let loaded = store.load_current(id).unwrap();
let mut expected = model.normalized();
expected.character_id = id;
assert_eq!(loaded, expected, "the stored revision 1 is the model that was passed in");
let revisions = store.list_revisions(id).unwrap();
assert_eq!(revisions.len(), 1);
assert_eq!((revisions[0].revision, revisions[0].source_server_id.as_str(), revisions[0].note.as_deref()), (1, "realm-a", Some("created")));
assert_eq!(store.list_characters(profile).unwrap().len(), 1);
}
#[test]
fn two_new_characters_never_share_an_id() {
let (mut store, profile) = fresh();
let a = created(&mut store, profile);
let b = created(&mut store, profile);
assert_ne!(a, b);
assert!(a < b, "ids are time-ordered");
}
#[test]
fn revision_increments_monotonically() {
let (mut store, profile) = fresh();
let id = created(&mut store, profile);
let mut model = store.load_current(id).unwrap();
for expected in 2..=6u64 {
model.progression.xp += 100;
let revision = store.commit_snapshot(id, expected - 1, model.clone(), "realm-b", Some("checkpoint")).unwrap();
assert_eq!(revision, expected);
assert_eq!(store.character(id).unwrap().revision, expected);
}
let revisions: Vec<u64> = store.list_revisions(id).unwrap().iter().map(|r| r.revision).collect();
assert_eq!(revisions, vec![1, 2, 3, 4, 5, 6]);
assert_eq!(store.load_current(id).unwrap().progression.xp, model.progression.xp);
assert_eq!(store.character(id).unwrap().level, 80);
}
#[test]
fn commit_updates_the_summary_row() {
let (mut store, profile) = fresh();
let id = created(&mut store, profile);
let mut model = store.load_current(id).unwrap();
model.identity.name = "Renamed".into();
model.progression.level = 81;
store.commit_snapshot(id, 1, model, "realm-a", None).unwrap();
let record = store.character(id).unwrap();
assert_eq!((record.name.as_str(), record.level, record.revision), ("Renamed", 81, 2));
}
#[test]
fn stale_revision_is_rejected_and_nothing_is_written() {
let (mut store, profile) = fresh();
let id = created(&mut store, profile);
let base = store.load_current(id).unwrap();
// a fresher state is committed first
store.commit_snapshot(id, 1, with_money(base.clone(), 500), "realm-a", None).unwrap();
let before = store.list_revisions(id).unwrap();
// a writer that still believes revision 1 is current must lose
let result = store.commit_snapshot(id, 1, with_money(base.clone(), 999_999), "realm-b", None);
assert!(matches!(result, Err(PortableError::StaleRevision { expected: 1, current: 2 })), "{result:?}");
// so must one that claims a revision from the future
let result = store.commit_snapshot(id, 7, with_money(base, 1), "realm-b", None);
assert!(matches!(result, Err(PortableError::StaleRevision { expected: 7, current: 2 })), "{result:?}");
assert_eq!(store.list_revisions(id).unwrap(), before, "a rejected commit leaves no trace");
assert_eq!(store.load_current(id).unwrap().progression.money, 500);
assert_eq!(store.character(id).unwrap().revision, 2);
}
#[test]
fn two_stores_on_one_file_cannot_both_win() {
let dir = tempfile::tempdir().unwrap();
let mut a = Store::open(dir.path()).unwrap();
let mut b = Store::open(dir.path()).unwrap();
let profile = a.default_profile().unwrap();
let id = created(&mut a, profile);
let base = b.load_current(id).unwrap();
a.commit_snapshot(id, 1, with_money(base.clone(), 10), "realm-a", None).unwrap();
let late = b.commit_snapshot(id, 1, with_money(base, 20), "realm-b", None);
assert!(matches!(late, Err(PortableError::StaleRevision { .. })), "{late:?}");
assert_eq!(b.load_current(id).unwrap().progression.money, 10);
}
#[test]
fn duplicate_uuid_is_rejected_and_the_original_is_untouched() {
let (mut store, profile) = fresh();
let id = created(&mut store, profile);
let original = store.load_current(id).unwrap();
let mut impostor = fixtures::naked_level_one();
impostor.character_id = id;
let result = store.create_character_with_id(profile, impostor, "realm-x");
assert!(matches!(result, Err(PortableError::DuplicateCharacter(dup)) if dup == id), "{result:?}");
assert_eq!(store.load_current(id).unwrap(), original);
assert_eq!(store.list_revisions(id).unwrap().len(), 1);
assert_eq!(store.list_characters(profile).unwrap().len(), 1);
}
#[test]
fn a_restored_character_keeps_its_own_identity() {
let (mut store, profile) = fresh();
let model = fixtures::naked_level_one();
let id = store.create_character_with_id(profile, model.clone(), "realm-a").unwrap();
assert_eq!(id, model.character_id);
assert_eq!(id, character_id(1));
}
#[test]
fn wrong_character_ruleset_and_unknown_character_are_refused() {
let (mut store, profile) = fresh();
let id = created(&mut store, profile);
let other = store.create_character(profile, fixtures::naked_level_one(), "realm-a").unwrap();
// a snapshot of another character cannot be committed under this id
let foreign = store.load_current(other).unwrap();
assert!(matches!(store.commit_snapshot(id, 1, foreign, "realm-a", None), Err(PortableError::WrongCharacter { .. })));
// CoA and Wildcard never mix
let mut wildcard = store.load_current(id).unwrap();
wildcard.ruleset = Ruleset::Wildcard;
assert!(matches!(store.commit_snapshot(id, 1, wildcard, "realm-a", None), Err(PortableError::RulesetChange { .. })));
let missing = CharacterId::new();
assert!(matches!(store.character(missing), Err(PortableError::UnknownCharacter(_))));
assert!(matches!(store.list_revisions(missing), Err(PortableError::UnknownCharacter(_))));
assert_eq!(store.character(id).unwrap().revision, 1);
}
#[test]
fn an_invalid_model_is_refused_without_side_effects() {
let (mut store, profile) = fresh();
let id = created(&mut store, profile);
let mut broken = store.load_current(id).unwrap();
broken.progression.money = u32::MAX;
assert!(store.commit_snapshot(id, 1, broken, "realm-a", None).is_err());
assert!(store.commit_snapshot(id, 1, store.load_current(id).unwrap(), "bad server id!", None).is_err());
assert_eq!(store.character(id).unwrap().revision, 1);
assert_eq!(store.list_revisions(id).unwrap().len(), 1);
}
#[test]
fn rollback_creates_a_new_revision_with_the_old_state() {
let (mut store, profile) = fresh();
let id = created(&mut store, profile);
let rev1 = store.load_current(id).unwrap();
store.commit_snapshot(id, 1, with_money(rev1.clone(), 111), "realm-a", None).unwrap();
store.commit_snapshot(id, 2, with_money(rev1.clone(), 222), "realm-a", None).unwrap();
let new = store.rollback_to(id, 3, 1, "manager").unwrap();
assert_eq!(new, 4, "revision numbers never go down");
assert_eq!(store.character(id).unwrap().revision, 4);
assert_eq!(store.load_current(id).unwrap(), rev1, "the state is revision 1's");
let revisions = store.list_revisions(id).unwrap();
assert_eq!(revisions.len(), 4, "history is kept, nothing is rewritten");
assert_eq!(revisions[3].note.as_deref(), Some("rollback to revision 1"));
assert_eq!(revisions[3].content_hash, revisions[0].content_hash);
assert_eq!(store.load_snapshot(id, 3).unwrap().progression.money, 222, "the state before the rollback is still there");
// a rollback is itself a revision: a stale caller cannot roll back on top of newer state
assert!(matches!(store.rollback_to(id, 3, 1, "manager"), Err(PortableError::StaleRevision { .. })));
assert!(matches!(store.rollback_to(id, 4, 99, "manager"), Err(PortableError::UnknownRevision { revision: 99, .. })));
assert_eq!(store.character(id).unwrap().revision, 4);
}
#[test]
fn history_is_capped_and_the_current_revision_survives() {
let (mut store, profile) = fresh();
assert_eq!(store.history_keep().unwrap(), DEFAULT_HISTORY_KEEP);
store.set_history_keep(3).unwrap();
assert_eq!(store.history_keep().unwrap(), 3);
let id = created(&mut store, profile);
let mut model = store.load_current(id).unwrap();
for rev in 1..=9u64 {
model.progression.xp = rev as u32;
store.commit_snapshot(id, rev, model.clone(), "realm-a", None).unwrap();
}
let revisions: Vec<u64> = store.list_revisions(id).unwrap().iter().map(|r| r.revision).collect();
assert_eq!(revisions, vec![8, 9, 10], "only the newest three remain");
assert_eq!(store.character(id).unwrap().revision, 10);
assert_eq!(store.load_current(id).unwrap().progression.xp, 9);
assert!(matches!(store.load_snapshot(id, 1), Err(PortableError::UnknownRevision { .. })));
// lowering the limit and pruning never removes the current revision
store.set_history_keep(1).unwrap();
assert_eq!(store.prune(id).unwrap(), 2);
assert_eq!(store.list_revisions(id).unwrap().len(), 1);
assert_eq!(store.load_current(id).unwrap().progression.xp, 9);
assert_eq!(store.prune(id).unwrap(), 0);
assert!(store.set_history_keep(0).is_err());
assert!(store.set_history_keep(MAX_HISTORY_KEEP + 1).is_err());
}
#[test]
fn a_damaged_snapshot_is_detected_not_trusted() {
let (mut store, profile) = fresh();
let id = created(&mut store, profile);
// flip bytes in the stored payload
store.conn.execute("UPDATE snapshot SET payload = CAST(substr(payload, 1, 20) || zeroblob(40) AS BLOB) WHERE character_id = ?1", [id.to_string()]).unwrap();
assert!(store.load_current(id).is_err());
// a payload that decodes but does not match the recorded hash
let (mut other, profile) = fresh();
let a = created(&mut other, profile);
let b = other.create_character(profile, fixtures::naked_level_one(), "realm-a").unwrap();
other.conn.execute(
"UPDATE snapshot SET payload = (SELECT payload FROM snapshot WHERE character_id = ?2) WHERE character_id = ?1",
params![a.to_string(), b.to_string()],
)
.unwrap();
assert!(matches!(other.load_current(a), Err(PortableError::CorruptSnapshot(_))));
}
#[test]
fn data_survives_reopening_the_file() {
let dir = tempfile::tempdir().unwrap();
let id;
{
let mut store = Store::open(dir.path()).unwrap();
let profile = store.default_profile().unwrap();
id = created(&mut store, profile);
let model = store.load_current(id).unwrap();
store.commit_snapshot(id, 1, with_money(model, 4242), "realm-a", None).unwrap();
store.set_history_keep(7).unwrap();
}
let mut store = Store::open(dir.path()).unwrap();
assert_eq!(store.character(id).unwrap().revision, 2);
assert_eq!(store.load_current(id).unwrap().progression.money, 4242);
assert_eq!(store.history_keep().unwrap(), 7);
let profile = store.default_profile().unwrap();
assert_eq!(store.list_characters(profile).unwrap().len(), 1);
assert!(Store::path_of(dir.path()).is_file());
}
#[test]
fn migrations_are_idempotent_and_newer_databases_are_refused() {
let dir = tempfile::tempdir().unwrap();
{
let store = Store::open(dir.path()).unwrap();
assert_eq!(store.schema_version().unwrap(), MIGRATIONS.len() as i64);
}
// opening again changes nothing
assert_eq!(Store::open(dir.path()).unwrap().schema_version().unwrap(), MIGRATIONS.len() as i64);
// a database written by a newer Manager is refused, not "migrated" downwards
{
let conn = Connection::open(Store::path_of(dir.path())).unwrap();
conn.pragma_update(None, "user_version", 99).unwrap();
}
assert!(matches!(Store::open(dir.path()), Err(PortableError::NewerDatabase { found: 99, .. })));
}
#[test]
fn a_foreign_sqlite_file_is_not_adopted() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join(DATABASE_FILE);
{
let conn = Connection::open(&file).unwrap();
conn.execute_batch("CREATE TABLE somebody_elses (x INTEGER); INSERT INTO somebody_elses VALUES (1);").unwrap();
}
assert!(matches!(Store::open(dir.path()), Err(PortableError::NotPortableDatabase)));
let conn = Connection::open(&file).unwrap();
let rows: i64 = conn.query_row("SELECT count(*) FROM somebody_elses", [], |r| r.get(0)).unwrap();
assert_eq!(rows, 1, "the foreign file is left alone");
}
#[test]
fn foreign_keys_are_enforced() {
let (store, _) = fresh();
let result = store.conn.execute(
"INSERT INTO character_server_mapping(character_id, server_id, local_guid, last_revision, state, updated_at) VALUES (?1, 'r', 1, 1, 'pending', 'x')",
[CharacterId::new().to_string()],
);
assert!(result.is_err(), "a mapping for a character that does not exist must be impossible");
}
#[test]
fn mapping_unique_constraints() {
let (mut store, profile) = fresh();
let a = created(&mut store, profile);
let b = store.create_character(profile, fixtures::naked_level_one(), "realm-a").unwrap();
store.bind_server(a, "realm-a", 154, 1, MappingState::Active).unwrap();
assert_eq!(store.find_by_local("realm-a", 154).unwrap(), Some(a));
assert_eq!(store.find_by_local("realm-a", 155).unwrap(), None);
assert_eq!(store.find_by_local("realm-z", 154).unwrap(), None, "guids are per realm");
// the same local guid cannot be claimed by another portable character
let taken = store.bind_server(b, "realm-a", 154, 1, MappingState::Active);
assert!(matches!(taken, Err(PortableError::LocalGuidTaken { local_guid: 154, .. })), "{taken:?}");
assert_eq!(store.find_by_local("realm-a", 154).unwrap(), Some(a));
// but the same number on another realm is a different character
store.bind_server(b, "realm-b", 154, 1, MappingState::Pending).unwrap();
assert_eq!(store.find_by_local("realm-b", 154).unwrap(), Some(b));
// one character, many realms, different local guids (the point of the whole design)
store.bind_server(a, "realm-b", 8421, 1, MappingState::Pending).unwrap();
store.bind_server(a, "realm-c", 391, 1, MappingState::Pending).unwrap();
let guids: Vec<(String, u32)> = store.server_mappings(a).unwrap().into_iter().map(|m| (m.server_id, m.local_guid)).collect();
assert_eq!(guids, vec![("realm-a".into(), 154), ("realm-b".into(), 8421), ("realm-c".into(), 391)]);
// rebinding updates in place
store.bind_server(a, "realm-a", 154, 1, MappingState::Synced).unwrap();
assert_eq!(store.server_mappings(a).unwrap()[0].state, MappingState::Synced);
// a binding cannot name a revision that does not exist yet, nor an unknown character
assert!(store.bind_server(a, "realm-a", 154, 5, MappingState::Synced).is_err());
assert!(matches!(store.bind_server(CharacterId::new(), "realm-a", 1, 1, MappingState::Pending), Err(PortableError::UnknownCharacter(_))));
}
#[test]
fn item_mapping_unique_per_character_and_server() {
let (mut store, profile) = fresh();
let id = created(&mut store, profile);
let model = store.load_current(id).unwrap();
let items: Vec<PortableItemId> = model.items.iter().take(4).map(|i| i.id).collect();
// an item mapping needs a server binding first
assert!(store.set_item_mappings(id, "realm-a", &[(items[0], 1)]).is_err());
store.bind_server(id, "realm-a", 154, 1, MappingState::Active).unwrap();
store.set_item_mappings(id, "realm-a", &[(items[0], 9001), (items[1], 9002), (items[2], 9003)]).unwrap();
assert_eq!(store.item_mappings(id, "realm-a").unwrap(), vec![(items[0], 9001), (items[1], 9002), (items[2], 9003)]);
// UNIQUE(character_id, server_id, local_item_guid): one local item guid cannot stand for two portable items
let clash = store.set_item_mappings(id, "realm-a", &[(items[0], 9001), (items[1], 9001)]);
assert!(matches!(clash, Err(PortableError::ItemGuidConflict { local_item_guid: 9001, .. })), "{clash:?}");
// and the same portable item cannot have two local guids
assert!(store.set_item_mappings(id, "realm-a", &[(items[0], 1), (items[0], 2)]).is_err());
// a failed replacement leaves the previous mappings intact
assert_eq!(store.item_mappings(id, "realm-a").unwrap().len(), 3);
// the database itself enforces the same rule, not just the Rust check
let direct = store.conn.execute(
"INSERT INTO item_mapping(character_id, server_id, portable_item_id, local_item_guid) VALUES (?1, 'realm-a', ?2, 9001)",
params![id.to_string(), items[3].to_string()],
);
assert!(direct.is_err(), "UNIQUE(character_id, server_id, local_item_guid)");
// the same local item guid on another realm is fine
store.bind_server(id, "realm-b", 8421, 1, MappingState::Active).unwrap();
store.set_item_mappings(id, "realm-b", &[(items[0], 9001)]).unwrap();
assert_eq!(store.item_mappings(id, "realm-b").unwrap(), vec![(items[0], 9001)]);
// if the local character changes (deleted and imported again), the old item guids are meaningless
store.bind_server(id, "realm-a", 777, 1, MappingState::Active).unwrap();
assert!(store.item_mappings(id, "realm-a").unwrap().is_empty());
assert_eq!(store.item_mappings(id, "realm-b").unwrap().len(), 1, "other realms are untouched");
}
#[test]
fn collection_union_never_removes_and_unchanged_means_no_revision() {
let (mut store, profile) = fresh();
let kind = "coa:wardrobe";
assert!(store.collection(profile, kind).unwrap().is_none());
// nothing stored, nothing incoming: nothing is created
let empty = store.merge_collection(profile, kind, &IdSet::new()).unwrap();
assert!(!empty.changed && empty.info.is_none());
let first = store.merge_collection(profile, kind, &IdSet::from_ids([10, 20, 30]).unwrap()).unwrap();
assert!(first.changed);
assert_eq!(first.added, 3);
let info1 = first.info.unwrap();
assert_eq!((info1.revision, info1.count), (1, 3));
// another realm returns a subset: nothing is lost, nothing changes
let subset = store.merge_collection(profile, kind, &IdSet::from_ids([20]).unwrap()).unwrap();
assert!(!subset.changed);
assert_eq!(subset.info.as_ref().unwrap(), &info1, "same revision, same hash");
let same = store.merge_collection(profile, kind, &IdSet::from_ids([10, 20, 30]).unwrap()).unwrap();
assert!(!same.changed);
// a realm with new unlocks (and without the old ones) only adds
let more = store.merge_collection(profile, kind, &IdSet::from_ids([30, 40, 5]).unwrap()).unwrap();
assert!(more.changed);
assert_eq!(more.added, 2);
let info2 = more.info.unwrap();
assert_eq!((info2.revision, info2.count), (2, 5));
assert_ne!(info2.hash, info1.hash);
let (info, set) = store.collection(profile, kind).unwrap().unwrap();
assert_eq!(set.ids(), &[5, 10, 20, 30, 40]);
assert_eq!(info, info2);
assert_eq!(store.collection_info(profile, kind).unwrap().unwrap(), info2);
// kinds are separate collections
assert!(store.collection_info(profile, "coa:vanity").unwrap().is_none());
assert!(store.merge_collection(profile, "wardrobe", &IdSet::new()).is_err(), "kinds must be namespaced");
}
#[test]
fn collections_hold_tens_of_thousands_of_ids_compactly() {
let (mut store, profile) = fresh();
for (kind, count) in [("coa:wardrobe", 2_000u32), ("coa:vanity", 10_000), ("mod:test:mounts", 50_000)] {
let ids: Vec<u32> = (0..count).map(|n| n * 7 + (n % 5)).collect();
let set = IdSet::from_ids(ids).unwrap();
let merge = store.merge_collection(profile, kind, &set).unwrap();
assert_eq!(merge.added, count as usize);
let stored_bytes: i64 = store.conn.query_row("SELECT length(payload) FROM collection WHERE kind = ?1", [kind], |r| r.get(0)).unwrap();
assert!(stored_bytes < count as i64 * 2 + 16, "{kind}: {count} ids took {stored_bytes} bytes");
let (_, back) = store.collection(profile, kind).unwrap().unwrap();
assert_eq!(back, set);
}
}
#[test]
fn a_tampered_collection_is_detected() {
let (mut store, profile) = fresh();
store.merge_collection(profile, "coa:vanity", &IdSet::from_ids([1, 2, 3]).unwrap()).unwrap();
let forged = IdSet::from_ids([1, 2, 3, 4]).unwrap().encode();
store.conn.execute("UPDATE collection SET payload = ?1", [forged]).unwrap();
assert!(matches!(store.collection(profile, "coa:vanity"), Err(PortableError::CorruptSnapshot(_))));
}
+18
View File
@@ -0,0 +1,18 @@
//! Format and protocol versions. They exist from the first commit so that nothing has to be guessed later.
//!
//! A reader refuses data whose version is **newer** than it knows; older versions are read through a migration.
//! The registry and relay versions are not used by anything yet (Phases 10 and 13); they live here so there is one
//! place that lists every version the portable system speaks.
/// Version of the serialised [`PortableCharacter`](super::model::PortableCharacter).
pub const PORTABLE_CHARACTER_FORMAT_VERSION: u32 = 1;
/// Version of the serialised collection set ([`IdSet`](super::collection::IdSet)).
pub const PORTABLE_COLLECTION_FORMAT_VERSION: u32 = 1;
/// Version of the extension container ([`Extension`](super::model::Extension)).
pub const EXTENSION_FORMAT_VERSION: u32 = 1;
/// Version of the snapshot envelope (header + compressed payload) that is stored and, later, transferred.
pub const SNAPSHOT_FORMAT_VERSION: u32 = 1;
/// Registry API protocol version (Phase 10).
pub const REGISTRY_PROTOCOL_VERSION: u32 = 1;
/// Relay protocol version (Phase 13).
pub const RELAY_PROTOCOL_VERSION: u32 = 1;
+91
View File
@@ -0,0 +1,91 @@
# Portable character format (Phase 1)
What is stored locally and how it is serialised. Code: `crates/coa-core/src/portable/`. Reasoning:
`PORTABLE_CHARACTERS_AUDIT.md`.
**Status:** format version 1 is *provisional* until the Phase 3 gate (Realm A -> Manager -> Realm B) passes. Until then
a change of shape may be made without a migration because nothing outside development machines holds version 1 data.
After that gate every change bumps the version.
## Versions (`versions.rs`)
| Constant | Value | Meaning |
|---|---|---|
| `PORTABLE_CHARACTER_FORMAT_VERSION` | 1 | the serialised `PortableCharacter` |
| `PORTABLE_COLLECTION_FORMAT_VERSION` | 1 | the encoded id set of a collection |
| `EXTENSION_FORMAT_VERSION` | 1 | the extension container |
| `SNAPSHOT_FORMAT_VERSION` | 1 | the stored snapshot envelope (compressed canonical JSON) |
| `REGISTRY_PROTOCOL_VERSION` | 1 | reserved for Phase 10 |
| `RELAY_PROTOCOL_VERSION` | 1 | reserved for Phase 13 |
A reader **refuses** data whose version is newer than it knows (checked before the shape is parsed), and reads older
versions through a migration.
## Identity
* `CharacterId`, `ProfileId`, `PortableItemId`, `PortablePetId` are **UUIDv7**, generated locally. Only version-7 ids
are accepted from text or JSON; a v4 or nil UUID is rejected as foreign.
* Local AzerothCore guids never appear in the model. They exist only in the mapping tables
(`character_server_mapping`, `item_mapping`), per realm.
* Game content is addressed as `<namespace>:<kind>:<id>` (`ContentId`), e.g. `core:wotlk:item:19019`, `coa:class:12`,
`mod:<module>:item:7`. The last two segments are kind and number; everything before is the namespace. Plain numbers
elsewhere in the model (spells, quests, factions, skills) are interpreted in the character's `content_namespace`.
## Canonical form and hash
1. The model is **normalised**: every set is sorted and de-duplicated (spells, talents, skills, glyph sets, items by
container/slot, quests, reputation, action buttons, pets and their spells, enchantments).
2. It is serialised as compact JSON with a fixed field order (`serde`), unknown fields refused on read.
3. `content_hash = SHA-256(canonical JSON)`. Equal characters have equal hashes; the hash does not depend on the
compressor.
4. The stored payload is `zstd(canonical JSON)`.
## Limits (hostile input)
`model::limits` caps every list and string; the snapshot is capped at 16 MiB decompressed and 8 MiB compressed, and
decompression stops at the cap while reading (decompression bombs). Money is capped at the realm's own maximum
(2,147,483,646 copper). Items must form a valid container tree (containers directly on the character, no two items in
one place, no duplicate ids). Extension payloads must match their recorded hash.
## Revisions
* `character.revision` is the canonical revision; freshness is decided by this integer, never by a timestamp.
* `commit_snapshot(expected_revision, ...)` succeeds only if `expected_revision` equals the canonical revision
(`StaleRevision` otherwise) and creates revision + 1, in one `BEGIN IMMEDIATE` transaction.
* `rollback_to` creates a **new** revision whose state equals an old one; revision numbers never decrease.
* The newest `history_keep` revisions are kept (default 20, 1..=1000, configurable). The current revision is never
pruned.
* A character's `ruleset` (`coa` | `wildcard`) can never change.
## Extensions
`extensions` maps an extension namespace (`mod:<module>`) to `{module_version, format_version, content_hash, payload}`.
The payload is opaque bytes: it is stored and returned unchanged, whether or not this Manager understands it. A realm
that does not support an extension simply does not receive it; the canonical copy is kept.
## Collections
Account-wide sets of numeric ids (`coa:wardrobe`, `coa:vanity`, ...), stored per profile and kind:
* union only: `stored = stored U incoming`; nothing is ever removed because a realm did not return it;
* if the union equals what is stored, **no revision is created** and the hash is unchanged;
* encoding: `[format version][count][first id][gap]...`, unsigned LEB128 varints, strictly ascending ids, 1-2 bytes per
id for realistic sets (50,000 scattered ids < 100 KB);
* `collection_hash = SHA-256("coa-collection-v1\0" + kind + "\0" + encoding)`.
## Store (`%LOCALAPPDATA%\CoAServerManager\portable\portable.db`)
SQLite, WAL, `synchronous=FULL`, `foreign_keys=ON`, `application_id = 0x434F4150`, forward-only migrations tracked in
`PRAGMA user_version`. A database from a newer Manager, or a foreign SQLite file, is refused and left untouched.
| Table | Purpose |
|---|---|
| `profile` | the owner |
| `character` | summary row + canonical revision |
| `snapshot` | `(character_id, revision)` -> compressed canonical payload + hash |
| `character_server_mapping` | `(character_id, server_id)` -> local guid; `UNIQUE(server_id, local_guid)` |
| `item_mapping` | `(character_id, server_id, portable_item_id)` -> local item guid; `UNIQUE(character_id, server_id, local_item_guid)` |
| `collection` | per profile and kind: revision, hash, encoded set |
| `setting` | `history_keep` |
The store never reads or writes a realm database and never leaves the machine.