Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,11 @@ _Avoid_: invite intent, group request, party order
**Home Shard**:
The shard that currently holds a Character's row.

**GUID Range**:
A disjoint interval assigned by Realm-core to one Shard. Character creation and item creation
consume the same durable high-water mark in that interval. Issued identities are never reused,
even after deletion or Transfer. Items add their wire type and format bits to the issued value.

**Shard Map**:
The rules that assign a map or instance to a shard.

Expand Down
68 changes: 68 additions & 0 deletions docs/design/item-guids.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Item GUID allocation

Characters and items consume the same durable high-water mark in a Shard's GUID Range.
Realm-core assigns disjoint billion-value ranges. `install_guid_range` refuses reassignment;
deleting a Character, deleting an item, Transfer and reinstalling the same range never rewind
the mark. A batch must fit below the range's exclusive end before the allocator advances.
The range now budgets Character creation and item creation together. Its base is the allocator
floor, leaving 999,999,999 issued identities per range.

New items retain `HIGHGUID_ITEM = 0x4000` in bits 48 through 63. Bit 47 marks this allocation
format. Bits 0 through 46 hold the issued value, so 140,737,488,355,327 is the largest usable
item allocation. Requests above that limit return `ITEM_GUID_EXHAUSTED` before advancing the
mark. Missing ranges return `NO_GUID_RANGE`; a batch crossing a range end returns
`GUID_RANGE_EXHAUSTED`. Items do not truncate or pack their Character owner's GUID.

Grants reserve complete batches before updating partial stacks or deleting Trading items.
Issued identities are never reused, including items consumed by a quest or craft earlier in
the same request. If a craft cannot grant all products, it restores the original carried
inventory with the original item GUIDs. Profession skill gains and automatic learning wait
until all direct product grants succeed. This restores carried inventory on a refused craft
without minting refund items from an exhausted range. Other effects retain their authored order.
This is not whole-cast rollback: a mixed recipe with triggered casts or other side effects needs
separate acceptance coverage. The committed conjuring fixture has one direct CreateItem effect;
it does not establish that every imported recipe has that shape.

An item keeps its GUID while moving between inventory slots and during Character Transfer.
Trading creates new recipient items. Mail, Auctions and Buyback store item snapshots and
create new items when the recipient takes them. Preserved identities now rely on disjoint
GUID Ranges and a durable issuer mark, rather than a packed Character namespace.

Transfer does not move or replace a Shard's allocator. With a range installed, an arriving
Character raises the local mark only when its GUID belongs to the destination's own range. Incoming items need no floor
update because the issuing Shard retains their high-water mark after they leave.

## Compatibility and rollout

No table, column, reducer argument or Transfer row format changes. Existing item rows and saved
Transfer Escrow keep their exact GUIDs. There is no automatic renumbering or data sweep.
Destination import checks arriving item ownership, duplicate GUIDs and local collisions before
inserting item rows. A conflict returns `ITEM_GUID_CONFLICT`; the reducer transaction preserves
the destination and source Escrow. It never deletes another Character's item to make room.

The old allocator normally used only the low 32 bits, but unchecked increments could escape
that range. No bit below the item prefix is provably unreachable by the old code. Before
deployment, an authorized Operator must inventory item GUIDs and saved Transfer Escrow across
every Shard. Record duplicate GUIDs with different owners, malformed item prefixes, legacy IDs
with bit 47 set, missing or overlapping GUID Ranges, and allocator marks at or beyond their
range end. Resolve those cases under separate human approval. Local collision checks do not
replace this realm-wide audit. An already escaped Character GUID also needs explicit review.

Without an installed range, Character cleanup retains the legacy allocator floor. Gateway
startup can continue after a failed range claim, so deleting a Character in that state must not
allow its GUID to be reused when provisioning recovers. Item creation still returns
`NO_GUID_RANGE`. Resolve missing ranges before deployment; the update never invents an assignment
or resets a mark.

Pause World Sessions and all item-granting or ownership-changing activity during the update.
Record outstanding Transfer Escrow. Verify each Shard's permanent range assignment, publish the
reviewed Module to every Shard, and confirm that all Shards run the same build before resuming
activity. An old Module would mint from a new item's maximum GUID without respecting the shared
allocator. Rolling back requires the same pause and a reviewed plan for new-format rows.
Never reset `game_guid_allocator`, reinstall a different range or delete the range registry.

Before deployment, obtain human review of this allocation and rollout plan. On an authorized
isolated realm, verify existing and new items through grant, split, crafting, Trading, Mail,
Buyback and round-trip Transfer, including a conflicting legacy arrival. Check actual 5875
client item rendering and slot moves. Unit tests and private reducer tests do not establish
real-client acceptance or prove a live realm's existing GUIDs are conflict-free.
59 changes: 59 additions & 0 deletions gateway/src/codec/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4097,3 +4097,62 @@ fn item_push_and_mail_keep_the_instance_property() {
117
);
}

#[test]
fn item_guid_allocation_bits_survive_item_and_container_wire_updates() {
for guid in [
0x4000_8000_0000_0800,
0x4000_8000_3b9a_ca01,
0x4000_ffff_ffff_ffff,
] {
for container_slots in [0, 8] {
let item = ItemInstanceView {
guid,
entry: 25,
owner_guid: 3_000_000_001,
slot: 23,
stack_count: 1,
container_slots,
..Default::default()
};
let mut bytes = Vec::new();
build_item_create_object(&item)
.write_unencrypted_server(&mut bytes)
.unwrap();
let ServerOpcodeMessage::SMSG_UPDATE_OBJECT(decoded) =
ServerOpcodeMessage::read_unencrypted(&mut bytes.as_slice()).unwrap()
else {
panic!("expected item object update");
};
match &decoded.objects[0] {
Object::CreateObject2 {
guid3,
object_type: ObjectType::Item,
mask2: UpdateMask::Item(fields),
..
} if container_slots == 0 => {
assert_eq!(guid3.guid(), guid);
assert_eq!(fields.object_guid(), Some(Guid::new(guid)));
}
Object::CreateObject2 {
guid3,
object_type: ObjectType::Container,
mask2: UpdateMask::Container(fields),
..
} if container_slots == 8 => {
assert_eq!(guid3.guid(), guid);
assert_eq!(fields.object_guid(), Some(Guid::new(guid)));
}
other => panic!("expected item or container, got {other:?}"),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
let mut slot_bytes = Vec::new();
build_inv_slot_values(item.owner_guid, item.slot, guid)
.unwrap()
.write_unencrypted_server(&mut slot_bytes)
.unwrap();
assert!(slot_bytes
.windows(8)
.any(|bytes| bytes == guid.to_le_bytes()));
}
}
}
2 changes: 2 additions & 0 deletions gateway/src/stdb/account_sessions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ mod tests {
let mut fixture = Standalone::start("account-transfer-destination");
fixture.publish_module();
fixture.assert_call("claim_operator", &[]);
fixture.assert_call("install_guid_range", &["1000000000"]);
let destination = runtime.block_on(Coordinator::connect(&GatewayConfig {
stdb_uri: fixture.server().into(),
module_name: fixture.shard_name().into(),
Expand Down Expand Up @@ -415,6 +416,7 @@ mod tests {
let mut fixture = Standalone::start("account-ownership");
fixture.publish_module();
fixture.assert_call("claim_operator", &[]);
fixture.assert_call("install_guid_range", &["0"]);
fixture.assert_call("gw_heartbeat", &[]);
let cfg = GatewayConfig {
logon_bind: "127.0.0.1:0".into(),
Expand Down
1 change: 1 addition & 0 deletions gateway/src/stdb/armor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ mod tests {
let mut shard = Standalone::start("property-armor");
shard.publish_module();
shard.assert_call("claim_operator", &[]);
shard.assert_call("install_guid_range", &["0"]);
shard.assert_call("debug_seed_scenario_fixtures", &[]);
shard.assert_call("debug_spawn_player_entity", &["1"]);
shard.assert_sql("DELETE FROM game_item_instance WHERE owner_guid = 1");
Expand Down
1 change: 1 addition & 0 deletions gateway/src/stdb/reducers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3652,6 +3652,7 @@ mod auction_reducer_tests {
let mut standalone = Standalone::start("auction-refusal");
standalone.publish_module();
standalone.assert_call("claim_operator", &[]);
standalone.assert_call("install_guid_range", &["0"]);
let cfg = GatewayConfig {
logon_bind: "127.0.0.1:0".into(),
world_bind: "127.0.0.1:0".into(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,15 @@ fn another_gateway_waits_for_the_transfer_then_cleans_the_deleted_character() {
standalone.assert_output_success(&output, &format!("publish {database}"));
}
standalone.assert_call("claim_operator", &[]);
standalone.assert_call("install_guid_range", &["2000000000"]);
for database in [INSTANCES, OTHER, REALM] {
let output = cli.call(standalone.server(), database, "claim_operator", &[]);
standalone.assert_output_success(&output, &format!("claim {database} operator"));
}
for (database, base) in [(INSTANCES, "0"), (OTHER, "1000000000")] {
let output = cli.call(standalone.server(), database, "install_guid_range", &[base]);
standalone.assert_output_success(&output, &format!("install range on {database}"));
}
for database in [INSTANCES, OTHER] {
let output = cli.call(
standalone.server(),
Expand All @@ -141,10 +146,6 @@ fn another_gateway_waits_for_the_transfer_then_cleans_the_deleted_character() {
);
standalone.assert_output_success(&output, &format!("clear Character 1 on {database}"));
}
for (database, base) in [(INSTANCES, "0"), (OTHER, "1000000000")] {
let output = cli.call(standalone.server(), database, "install_guid_range", &[base]);
standalone.assert_output_success(&output, &format!("install range on {database}"));
}
for (database, name) in [(INSTANCES, "PartyTwo"), (OTHER, "PartyOther")] {
let output = cli.call(
standalone.server(),
Expand Down
1 change: 1 addition & 0 deletions gateway/src/world/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10091,6 +10091,7 @@ fn competing_world_sessions_close_the_old_socket_without_removing_the_winner() {
let mut fixture = Standalone::start("account-world-sessions");
fixture.publish_module();
fixture.assert_call("claim_operator", &[]);
fixture.assert_call("install_guid_range", &["0"]);
fixture.assert_call("gw_heartbeat", &[]);
let cfg = GatewayConfig {
logon_bind: "127.0.0.1:0".into(),
Expand Down
Loading