Files
coa-server-manager/docs/PORTABLE_FORMAT.md
Дмитрий 5343020bed feat(portable): MR2 Wildcard portable character vertical slice
- Enforce fail-closed backend resolution in LocalBackend::info (unknown ruleset = error).
- Implement strict three-space isolation (Wildcard != CoA != Reborn) across canonical store, host mappings, and collections.
- Isolate collection namespaces (wildcard:appearance, wildcard:vanity) and reject cross-ruleset collections in claim and import.
- Enforce Control Protocol v2 ruleset verification on TransferOffer, TransferCommit, and Claimed response.
- Route Direct Ingress and Relay Link dynamically according to realm world port (8085 for CoA, 8086 for Wildcard).
- Add runtime session attack rejection test suite (cross-ruleset session offers, checkpoints, owner acks, server IDs).
- Verify end-to-end Wildcard session lifecycle and documentation updates.
2026-10-22 17:24:15 +05:00

8.9 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: the Phase 3 gate passed with format 1; since then every change of shape bumps the version. Phase 6 added the optional wardrobe section, so the character format is now 2 (Phase 6.1); version 1 is still read, through the strict migration below.

Versions (versions.rs)

Constant Value Meaning
PORTABLE_CHARACTER_FORMAT_VERSION 2 the serialised PortableCharacter this build writes
PORTABLE_CHARACTER_MIN_READ_VERSION 1 the oldest character format this build still reads
ONLINE_IMPORT_JOB_FORMAT_VERSION 2 the online import job (<job_id>.job) the core reads; checked by the core before it parses the body
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.

Character format 1 -> 2 (Phase 6.1)

Format 2 is format 1 plus the optional wardrobe section (absent when empty, so an empty one serialises to the same bytes as before apart from format_version). The migration is strict and lives in snapshot.rs:

  1. the payload is decompressed under the size caps and its SHA-256 is verified against the stored hash before anything is interpreted or migrated (the original format 1 hash is what was recorded; a payload that does not match it is corrupt);
  2. format_version is read first; newer than 2 is refused, 0 or non-numeric is refused;
  3. a format 1 payload that contains a wardrobe (even an empty object) is rejected: format 1 had none, so it is not a format 1 payload;
  4. a genuine format 1 payload becomes a format 2 character with an empty wardrobe; deny_unknown_fields still applies to everything else.

Stored snapshots are never rewritten. The same character has a different hash in the two formats, so anything that asks "is this the same character?" compares snapshot::semantic_hash (the hash of the character as format 2 writes it, for a stored payload of either version) and never a stored hash with a freshly encoded one (the owner's checkpoint head check, the reconcile head check, the Host's same-revision check).

Online import job format 2

The header gained job_format (first field). The core reads the header line first and refuses a job whose job_format it does not read, or that has none (what Phase 5 wrote), with unsupported_job_format and the list of formats it reads, before it computes the body hash or parses the body; the body must be character format 2 (wardrobe optional).

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 | reborn) can never change.
  • The character's content_namespace aligns strictly with ruleset (coa, wildcard, or reborn).

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:appearance, wildcard:appearance, reborn:appearance, etc.), stored per (profile_id, ruleset, kind):

  • partitioned strictly by ruleset: collections of different rulesets never intersect or merge;
  • 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 (migration 009 adds multi-ruleset collection partitioning and reborn character support). 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 (ruleset IN ('coa', 'wildcard', 'reborn'))
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, ruleset, 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) and UNIQUE(.., 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.)