Imports a canonical snapshot into a stopped disposable realm through the existing Db abstraction, in one MySQL transaction: guids allocated inside the transaction from the realm's own tables and every reference remapped, orphan cleanup, name and start-position handling, self-asserting result, and a marker row. Untrusted strings and blobs reach SQL only as hex literals from a typed encoder. Manager mappings are recorded only after the realm commit, in one local transaction; an import journal (schema 3) plus the marker recover a crash between the two commits. Preflight refuses wrong ruleset, running realm, missing account/content and more before anything is written. Stock character_talent rows are never written: a real worldserver asserts on a non-talent row. Item identity no longer includes the crafter (v2). Production import remains the designed in-core PortableImportService (docs/PORTABLE_IMPORT_SERVICE.md); nothing of it is implemented. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
6.6 KiB
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,PortablePetIdare 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'scontent_namespace.
Canonical form and hash
- 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).
- It is serialised as compact JSON with a fixed field order (
serde), unknown fields refused on read. content_hash = SHA-256(canonical JSON). Equal characters have equal hashes; the hash does not depend on the compressor.- 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.revisionis the canonical revision; freshness is decided by this integer, never by a timestamp.commit_snapshot(expected_revision, ...)succeeds only ifexpected_revisionequals the canonical revision (StaleRevisionotherwise) and creates revision + 1, in oneBEGIN IMMEDIATEtransaction.rollback_tocreates a new revision whose state equals an old one; revision numbers never decrease.- The newest
history_keeprevisions 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 (currently 3). 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 |
one row per mapping with a lifecycle (schema 2, see below) |
import_journal |
schema 3: one row per import into a realm (prepared / committed / aborted / needs_attention) with the nonce/marker and the plan; see PORTABLE_IMPORT.md |
collection |
per profile and kind: revision, hash, encoded set |
setting |
history_keep |
Item mapping lifecycle (schema 2)
A realm's item guid counter restarts at MAX(guid)+1, so a freed guid can be given to a different item. A bare
guid -> portable item mapping would attach the new item to the old portable id. Every mapping therefore records what
the item was (entry, identity) and has a state:
active: at most one per portable item and at most one per local item guid (UNIQUE(character_id, server_id, local_item_guid)andUNIQUE(.., portable_item_id), both for active rows);retired(kept for the record) with a reason:guid_reused,moved,absent,character_rebound.
Store::reconcile_item_mappings takes the complete observation of a character on a realm and confirms, adds or retires
mappings; Store::resolve_item_ids answers Known, Reused (recycled guid) or Unmapped for a local guid and its
current identity. Schema-1 rows are migrated with an empty identity, which matches nothing: they are re-verified, never
trusted. Rebinding a portable character to another local guid retires its item mappings.
The identity covers what cannot change during an item's life (entry, random property, crafter); two different items that share all of these are interchangeable for mapping purposes.
The store never reads or writes a realm database and never leaves the machine. (Phase 2's realm reader lives in
portable/realm/ and is documented in PORTABLE_EXPORT.md; it only reads.)