feat(shared): pin block handles to a per-world id table in level.dat #10

Merged
Serkyo merged 6 commits from feat/level-id-table into dev 2026-09-28 21:33:57 +00:00
Owner

What does this PR do?

I gave every world a block ID table in level.dat and made block handles come from it instead of from registration order.

  • WorldMetadata in crates/shared/src/save/level.rs now carries block_ids: Vec<ContentId>, where the ID at index i owns BlockId(i) for the life of the world. I bumped WORLD_FORMAT_VERSION from 2 to 3.
  • RegistryBuilder in crates/shared/src/registry.rs no longer assigns handles. register returns (), and I replaced freeze with bind(&mut Vec<ContentId>). An ID already in the table keeps its index, a new ID is appended, and a table entry no registered ID matches stays as an orphan whose slot is never reused. bind returns a Bound holding the Registry, the number of appended IDs, and a live: Vec<bool> indexed by handle. Registry entries are now Option so orphaned handles resolve to None.
  • WorldMetadata::bind_blocks adds the block-only rule that syn:air sits at index 0 and reports RegistryError::MisplacedAir otherwise. SaveError::Registry wraps registry errors.
  • save::load_or_create in crates/server/src/save/level_file.rs seeds the table from registration order for a new world, binds on every open, logs each orphan with warn!, and writes the table back with the timestamp. main.rs registers syn:air plus the three placeholder blocks through register_blocks.
  • Chunk::replace_orphans_with_air turns any voxel with a non-live handle into air, including a handle past the end of the table. load_chunk in world_server.rs calls it on saved chunks only, since the generator emits registered blocks alone. ServerWorld::new takes the live vector and shares it with the workers.
  • I documented the table and the orphan behaviour in docs/save_format.md.

Why is this change necessary?

ChunkData stores raw BlockId handles, and handles followed registration order. Adding, removing, or reordering a block pack would shift every handle after it and silently turn saved edits into different blocks. ADR-0005 requires content IDs to be interned to handles at runtime, and that only holds across sessions if each world records which ID owns which handle.

Scope of Changes

server, shared, workspace

Testing

Automated:

  • cargo check -p shared -p server: passed.
  • cargo test -p shared: 277 passed.
  • cargo test -p server: 59 passed.
  • cargo clippy --all-targets --all-features -- -D warnings: clean.
  • cargo fmt --all -- --check: clean.

New tests cover rebinding with the same registration, appending a new ID, reordered registration resolving to the original handles, a removed ID keeping its slot, refusing a table without air first or past the last u16 handle, refusing version 1 and version 2 level.dat records, seeding the table and keeping it across reloads, persisting an appended block, replace_orphans_with_air on orphaned and out-of-table handles, and an orphaned handle loading as air and leaving the record on write-back.

Manual: no manual verification.

Additional Context

  • level.dat moved from format version 2 to 3 with no migrator, so existing dev worlds are refused with UnsupportedVersion and have to be recreated.
  • RegistryBuilder::freeze is gone and register no longer returns a handle. Handles only exist after binding against a table.
  • An orphaned block does not stop the world from opening. I chose to load it as air rather than refuse the world. Region files are not rewritten at open: a chunk that gets loaded and evicted writes the air back, while a chunk never visited keeps its handles and gets its blocks back if the pack is reinstalled.
  • BLOCK_IDS in main.rs is a placeholder until the data loader builds the registry. The table only covers blocks for now. An item_ids table follows the same rules once something persists an ItemId.

Checklist

  • I have branched from dev (or a feature branch off dev) and my PR targets dev.
  • I have kept my changes focused to a single concept.
  • I have added or updated documentation (/// doc comments for Rust) where necessary.
  • I have tested my changes and described any relevant automated or manual testing above.
### What does this PR do? I gave every world a block ID table in `level.dat` and made block handles come from it instead of from registration order. - `WorldMetadata` in `crates/shared/src/save/level.rs` now carries `block_ids: Vec<ContentId>`, where the ID at index `i` owns `BlockId(i)` for the life of the world. I bumped `WORLD_FORMAT_VERSION` from 2 to 3. - `RegistryBuilder` in `crates/shared/src/registry.rs` no longer assigns handles. `register` returns `()`, and I replaced `freeze` with `bind(&mut Vec<ContentId>)`. An ID already in the table keeps its index, a new ID is appended, and a table entry no registered ID matches stays as an orphan whose slot is never reused. `bind` returns a `Bound` holding the `Registry`, the number of appended IDs, and a `live: Vec<bool>` indexed by handle. `Registry` entries are now `Option` so orphaned handles resolve to `None`. - `WorldMetadata::bind_blocks` adds the block-only rule that `syn:air` sits at index 0 and reports `RegistryError::MisplacedAir` otherwise. `SaveError::Registry` wraps registry errors. - `save::load_or_create` in `crates/server/src/save/level_file.rs` seeds the table from registration order for a new world, binds on every open, logs each orphan with `warn!`, and writes the table back with the timestamp. `main.rs` registers `syn:air` plus the three placeholder blocks through `register_blocks`. - `Chunk::replace_orphans_with_air` turns any voxel with a non-live handle into air, including a handle past the end of the table. `load_chunk` in `world_server.rs` calls it on saved chunks only, since the generator emits registered blocks alone. `ServerWorld::new` takes the `live` vector and shares it with the workers. - I documented the table and the orphan behaviour in `docs/save_format.md`. ### Why is this change necessary? `ChunkData` stores raw `BlockId` handles, and handles followed registration order. Adding, removing, or reordering a block pack would shift every handle after it and silently turn saved edits into different blocks. ADR-0005 requires content IDs to be interned to handles at runtime, and that only holds across sessions if each world records which ID owns which handle. ### Scope of Changes server, shared, workspace ### Testing Automated: - `cargo check -p shared -p server`: passed. - `cargo test -p shared`: 277 passed. - `cargo test -p server`: 59 passed. - `cargo clippy --all-targets --all-features -- -D warnings`: clean. - `cargo fmt --all -- --check`: clean. New tests cover rebinding with the same registration, appending a new ID, reordered registration resolving to the original handles, a removed ID keeping its slot, refusing a table without air first or past the last `u16` handle, refusing version 1 and version 2 `level.dat` records, seeding the table and keeping it across reloads, persisting an appended block, `replace_orphans_with_air` on orphaned and out-of-table handles, and an orphaned handle loading as air and leaving the record on write-back. Manual: no manual verification. ### Additional Context - `level.dat` moved from format version 2 to 3 with no migrator, so existing dev worlds are refused with `UnsupportedVersion` and have to be recreated. - `RegistryBuilder::freeze` is gone and `register` no longer returns a handle. Handles only exist after binding against a table. - An orphaned block does not stop the world from opening. I chose to load it as air rather than refuse the world. Region files are not rewritten at open: a chunk that gets loaded and evicted writes the air back, while a chunk never visited keeps its handles and gets its blocks back if the pack is reinstalled. - `BLOCK_IDS` in `main.rs` is a placeholder until the data loader builds the registry. The table only covers blocks for now. An `item_ids` table follows the same rules once something persists an `ItemId`. ### Checklist - [x] I have branched from `dev` (or a feature branch off `dev`) and my PR targets `dev`. - [x] I have kept my changes focused to a single concept. - [x] I have added or updated documentation (`///` doc comments for Rust) where necessary. - [x] I have tested my changes and described any relevant automated or manual testing above.
docs(workspace): document orphaned block handles loading as air
All checks were successful
Auto Labeler / label-scope (pull_request_target) Successful in 2s
CLA Signed All authors have signed the CLA.
CLA Check / cla-check (pull_request_target) Successful in 2s
CI / Rust Check, Lint & Test (pull_request) Successful in 34m30s
CI / Dependency Licenses & Advisories (pull_request) Successful in 29s
CI / Lua Lint & Format (pull_request) Successful in 7s
CI / Commit Message Lint (pull_request) Successful in 2s
CI / LFS Pointer Guard (pull_request) Successful in 5s
ea99bb78db
Serkyo deleted branch feat/level-id-table 2026-09-28 21:33:57 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Synvael/synvael!10
No description provided.