Skip to content

feat: client-only inventory sorting (MVP) - #1

Merged
amatouhake merged 10 commits into
mainfrom
feat/inventory-sort
Sep 20, 2026
Merged

amatouhake merged 10 commits into
mainfrom
feat/inventory-sort

Conversation

@amatouhake

@amatouhake amatouhake commented Sep 20, 2026 •

Copy link
Copy Markdown
Owner

What does this PR do?

Implements the LaminaSort MVP: a small, client-only inventory sorter for LeviLamina Client 26.51.3 (Minecraft Bedrock 1.26.51.x). Pressing the Sort key while an inventory or container screen is open consolidates compatible partial stacks and arranges the region into a zero-config, human-oriented default order (Shulker Boxes, gear, items, blocks) built on the game's own Creative registry, using the same client inventory machinery the mouse uses.

MVP scope

  • Sort key (default R; registered through LeviLamina's KeyRegistry, so it appears in and is remappable from the game's Keyboard & Mouse settings as LaminaSort.sort). The key is ignored while a text box (Creative search, anvil name, ...) is being edited and while an item is held on the cursor.
  • Sorts the main 27 player inventory slots from any container screen (survival inventory, creative inventory tab, chest screen...).
  • Container sorting is included: when an ordinary storage container is open and the pointer hovers one of its slots, that container is sorted instead. Only ContainerType::Container (chest, trapped chest, ender chest, barrel, shulker box), MinecartChest and ChestBoat qualify; furnace, blast furnace, smoker, brewing stand, crafting, smithing, enchanting, anvil, loom etc. are never sorted.
  • Vanilla item locks are honoured: a lock_in_slot stack stays exactly where it is (never merged into or out of) and the rest of the region is sorted around it; a lock_in_inventory stack may still change slots inside the player inventory (as vanilla allows) and is left alone inside a container region.
  • Explicit trigger only; nothing runs in the background.

Client-only architecture / boundary

The mod needs only the Minecraft client with LeviLamina. No server component, operator permission, cheat, command, experiment or direct mutation of authoritative inventory state. Every change goes through the vanilla ItemStackRequest path, so the server validates every step and vanilla itself reverts anything it rejects.

Inventory operation path reused from vanilla

Traced from ContainerScreenController (the screen the mouse talks to) down to ContainerManagerController -> _transfer() -> ItemStackRequestScope -> ItemStackNetManagerClient (predictive containers + server-validated request). LaminaSort calls the two ContainerManagerController entry points that the UI uses:

  • handlePlaceAmount(src, count, dst) - a Place transfer of count items (the path behind shift-click / drag placement). Used for every merge and every move into an empty slot.
  • handleSwap(a, b) - the Swap transfer behind the hotbar hotkey swap. Used only for two occupied slots of different kinds.

No cursor is involved, no packets are built by hand, and no container/model state is written directly.

Stack compatibility semantics

Two stacks are mergeable iff vanilla's own ItemStackBase::isStackable(other) says so - the check the game uses when stacks are combined in the UI. Damage, enchantments, custom names, lore, components, NBT, potion variants, shulker contents, can_place_on / can_destroy restrictions and unstackable items (max stack 1) are therefore kept apart exactly as the inventory screen keeps them. LaminaSort defines no equality of its own; the planner only sees an opaque group id.

Stack consolidation

Part of the MVP. For each mergeable group the lowest-index stacks are topped up from the highest-index ones, respecting the item's own max stack size, so every group ends with the minimum number of stacks and at most one partial one: 42 + 10 -> 52, 42 + 30 -> 64 + 8, 42 + 10 + 25 -> 64 + 13, 15 + 15 eggs -> 16 + 14. Max-stack-1 items are never merged. Locked (fixed) slots take no part in consolidation.

Sort ordering (zero-config default)

A small semantic layer on top of the game's own Creative registry — no profiles, no configuration, no maintained item lists:

  1. Shulker Boxes (every colour/variant, recognised by item class ShulkerBoxBlockItem, not by identifier) — filled boxes before empty ones ("filled" = the real Items contents hold at least one non-empty stack), then custom name, then a content signature, then colour; the NBT hash only breaks remaining ties. The signature is built from the inner stacks' full ordering keys (custom name, enchantments, damage, ... but never nested contents, so it is bounded to one level), merged per kind and sorted, so two boxes with the same items in different internal slots compare equal while a box holding an Efficiency V pickaxe, one with a plain pickaxe and one with a worn pickaxe are all distinct.
  2. Equipment — tools, weapons, armour, bows, shields, elytra… (the registry entry's Creative category).
  3. Items — materials, potions, utility, misc, and food (Item::isFood; Bedrock files most food under Equipment/Nature, this is the one category override besides Shulker Boxes).
  4. Blocks — Construction, then Nature. Equipment no longer splits the block regions.
  5. Unknown / custom items (not in the Creative registry) — stable identifier order.
  6. Empty slots.

Inside a section: the Creative registry group and entry order. Variants of one base item (already separated by vanilla stackability): custom-named first (name normalised: formatting codes stripped, case-folded), then enchanted (canonical list in enchantment registry id order, higher level first, longer list first — identity before any summed "power"), then plain; then damage ascending (better condition first); then the last-resort hash. Within otherwise equal stacks: count descending (64, 64, 12).

Design references reviewed (behaviour only, no code copied): Quark SortingHandler (class-based types, blocks last, tier → enchantment power → damage), Inventory Profiles Next default rules (custom_name → creative order → enchantments in tooltip order → damage → components), Mouse Wheelie (creative order; count → custom-named first → tooltip lines). Named-first and better-durability-first are the common conventions; IPN's global named-first was considered and rejected in favour of named-first within the item kind, which reads better in a chest.

Protected / excluded slots

Hotbar, armour, offhand, crafting input/output, cursor and every other collection are never addressed: the planner only ever sees the selected region (inventory_items or the container's own collection) and every operation's source and destination lie inside it. Item-locked slots inside the region are cut out of the plan (SlotStack::fixed()): the planner sorts the movable slots as a contiguous view and maps operations back, and applyOperation refuses any step that touches a fixed slot.

Safety / abort behaviour

Before each step the two touched slots are compared with the planner's simulation (count + matchesItem against a snapshot of the group's representative); after each step they are compared again and the transfer's return value is checked. Any mismatch or refusal aborts the remaining steps with a logged reason; an item held on the cursor skips the sort entirely. Steps already issued are ordinary server-validated transfers, so an aborted sort is still a valid inventory.

Server rejection semantics (precise): the checks above compare against the client-side prediction. The server's verdict arrives asynchronously, after the whole sequence of steps has been issued, and is not fed back into the sort session. If the server rejects a request, vanilla discards the client's prediction for that step and restores the server's slot contents, exactly as for a rejected manual drag; LaminaSort only observes ItemStackResponsePacket and logs a warning per rejection (and, in trace builds, the accepted count). It never retries, re-plans or "fixes up" a rejected step.

Automated tests

LaminaSortTests: 24 ordering tests (SortKeyTest.cpp) and 30 planner tests (SortPlannerTest.cpp), all passing.

  • Ordering: top-level sections incl. an unknown item, Equipment not splitting blocks, every Shulker before non-Shulkers, filled→empty→filled becoming filled→filled→empty, all 17 colours staying in the Shulker section, slot-layout-independent content signatures, deterministic ordering of different contents, inner enchanted vs plain / inner named variants / inner durability variants keep boxes apart, and those variants still ignore the internal slot arrangement, named boxes, hash differences not scrambling boxes, empty boxes by name then colour, enchanted vs plain, same enchantment higher level first, different enchantments by identity (no summed power), multi-enchantment prefix rule, enchanted books, named variants first, durability better-first, hash as last resort, enchantment canonicalisation.
  • Planner: empty inventory, already sorted, one item, multiple items, 42 + 10 -> 52, 42 + 30 -> 64 + 8, separated partial stacks, more than two partial stacks, many partials -> minimal stack count, already-full stacks, full + partial, max-stack-16 (incl. no overshoot), non-stackable items (incl. defensive same-group case), look-alike incompatible stacks, look-alikes with equal keys stay contiguous, empty-slot placement, determinism, idempotent re-sort, operations stay inside the region, full 27-slot region (swaps only), creative index before name, locked slot stays and others sort around it, locked slot never merged into or out of, locked slots idempotent, all slots locked is a no-op, locked empty slot is just empty, applyOperation rejects fixed slots, invalid operations rejected. Every planner test also asserts the invariants that fixed slots are untouched and group totals are preserved.

These use synthetic group ids and deliberately do not claim vanilla compatibility; that is decided at runtime by isStackable.

Environment recovery and LeviLamina 26.51.3 migration (this revision)

The local LeviLamina client environment was broken when this revision started (LeviLauncher's LIP install of LeviLamina failed with ERR_LIP_INSTALL_FAILED while fetching the bedrock-runtime-data release asset). Root cause, with evidence from the launcher log and direct downloads: LIP 0.34.8's downloader has a 100 s HTTP client timeout, and on this connection release-assets.githubusercontent.com delivered the 40.8 MB bedrock-runtime-data-v26.51.1-client.4-windows-x64.zip at ~0.2–0.5 MB/s (80–185 s per attempt), so every attempt timed out at ~100 s. No proxy, DNS, TLS or firewall problem was found (a direct curl download succeeded with a matching SHA-256). Recovery used normal package management: the same LIP 0.34.8 CLI, run against the existing instance, installed github.com/LiteLDev/LeviLamina#client@26.51.3 on an attempt where the download finished just under the timeout; nothing was copied into place by hand, the instance was preserved, and LeviLauncher then reported LeviLamina 26.51.3 installed. Minecraft boots and shows v26.51/LeviLamina-26.51.3.

Migration: xmake.lua targets levilamina v26.51.3 (git-tag form until the LeviMC xmake repo lists it; the SDK is built from source, which needs LLVM 22.1.8 rather than the VS-bundled clang). Every SDK header LaminaSort depends on is byte-identical between 26.51.1 and 26.51.3 except ll/api/io/FileSink.h, whose constructor now takes a RotatePolicy; the --trace=y build uses RotatePolicy::disabled(). README/CHANGELOG updated accordingly.

LaminaPeek compatibility (reported only, repository not modified): the sibling LaminaPeek mod (built for 26.51.1) loads and enables alongside LaminaSort on LeviLamina 26.51.3 without any change; it does not use FileSink, so no source change is needed there.

Local Minecraft runtime validation performed by Claude

All in a fresh, disposable local creative world ("LaminaSort Test") on the existing LeviLauncher instance. Items were placed with /replaceitem / /setblock (cheats enabled on the test world only for setup; the mod itself uses no commands). Claude connected to no multiplayer server, Realm, BDS, LAN world or remote host at any point; all of Claude's testing was in local single-player worlds. (A "trust this server?" prompt for a LAN-advertised world appeared once in the world list during the first task and was declined.)

This revision, on LeviLamina 26.51.3 (trace build for the detailed checks, then the final release build):

  • Environment: game boots with LeviLamina 26.51.3; LaminaSort and LaminaPeek both load and enable.
  • Sort hotkey vs text input: typing r/R into the Creative search box and into the anvil name box no longer triggers a sort (logged "Sort key pressed while a text box is being edited; ignored"); after deselecting the box the key sorts again.
  • Item locks: a lock_in_slot oak log in the player inventory stayed in its slot while the rest sorted around it (logged "1 item-locked slot(s) stay in place"); a lock_in_inventory apple moved within the inventory. Re-checked on the release build: 14 operations applied, all accepted, second press "already sorted".
  • Shulker signature: 54-slot large chest with boxes holding an Efficiency V pickaxe / a plain pickaxe + cobblestone / a worn pickaxe / identical cobble+stone contents in different internal slots / eggs, an anvil-renamed empty "spares" box, undyed and white empty boxes — one press ordered them pink (Eff V) → lime (plain + cobble) → orange (worn) → the two identical-content boxes side by side → yellow (eggs) → cyan "spares" → undyed → white; 38/38 requests accepted; second press idempotent.
  • Consolidation regression: 42 + 10 → 52 (diorite) and 42 + 30 → 64 + 8 (tuff).
  • Cursor-held item → sort skipped; save & quit → re-enter: sorted chest and inventory persisted.
  • Shulker box screen: its slots are addressed as container_items, so the region selection covers it.

Earlier revisions (LeviLamina 26.51.1, same local world): empty inventory no-op; scrambled 27-slot region sorted into creative order with hotbar/armour/shield untouched; 4 × 20 dirt → 64 + 16, 15 + 15 + 2 eggs → 16 + 16, 10 + 9 + 3 eggs → 16 + 6, full 64 + 64 untouched, 64 + 1 kept; two swords / damaged vs undamaged pickaxe stay separate; can_place_on cobblestone merged only among itself (1 + 19 + 20 → 40) and never with plain (vanilla behaviour demonstrated by hand first); idempotency (second press 0 operations, three rapid presses no change); every issued request acknowledged (19/19, 13/13, 11/11, 7/7, 5/5, 3/3, 1/1, 40/40, 32/32); chest vs inventory region selection by hovered slot; 54-slot chest with the full section layout (filled boxes → named empty box → empty boxes; helmet, chestplate, swords, "Old Faithful", Efficiency+Unbreaking, Efficiency V, Fortune III, plain, damaged 600/1400 pickaxes, bow, arrows, shield; golden apple, bread, torch, coal, iron, string; planks 64+6, glass, cobblestone, dirt, stone, sand, logs 64+20, seeds); contents changed manually / by command between sorts; screens closed/reopened between sorts. No ghost, missing or duplicated items, no reverts, no UI breakage, no crash observed.

Owner-reported manual validation (not performed by Claude)

  • Sorting a 9×6 large/double chest (54 slots) with an earlier build.
  • A multiplayer server smoke test with an earlier build. Claude has not tested against any server; the asynchronous-rejection behaviour described above has therefore only been exercised locally, where no rejection ever occurred.

Remaining validation (not yet exercised in game by anyone, as far as this PR knows)

  • Barrel, ender chest, chest minecart and chest boat screens (their container types are accepted generically; only chests and a shulker box screen were exercised).
  • A lock_in_slot item inside a container region (only the player-inventory case was exercised; the planner treats both the same).
  • Potion variants, enchanted books, lore/component-only differences and add-on/custom items in game (covered by isStackable semantics and unit tests, not exercised at runtime).
  • Survival mode (all runtime tests ran in creative; the transfer path is the same).
  • Server-side rejection of a sort step on a real multiplayer host (out of scope for this PR; only the vanilla revert path is relied on).

Version-sensitive hooks / symbols

No signatures or fixed offsets. Everything goes through the LeviLamina 26.51.3 SDK headers:

  • Hooks: ContainerScreenController::$onLeave, ScreenView::_fireSelectedStateChangeEvent(TextEditComponent const&, bool) (text-box focus), ClientNetworkHandler::$handle(NetworkIdentifier const&, ItemStackResponsePacket const&) (diagnostic only).
  • Called: ContainerManagerController::{handlePlaceAmount, handleSwap, getItemStack, getContainerSize, hasContainerController, getContainerType}, ContainerScreenController::_isCursorSelectedActive, ItemStackBase::{isStackable, matchesItem, getMaxStackSize, getCustomName, getDamageValue, ...}, ItemLockHelper::getItemLockMode, ShulkerBoxBlockItem::$vftable() (class identity), Item::isFood() (virtual), ItemStack::fromTag, Level::getItemRegistry().getCreativeItemRegistry().
  • Struct layouts relied on via TypedStorage: ContainerScreenController::{mContainerManagerController, mHoveredSlotData}, ScreenView::mController, CreativeItemRegistry::{mCreativeItems, mCreativeGroups}, CreativeItemEntry::{mGroupIndex, mIndex, mItemInstance}, CreativeGroupInfo::mCategory, ItemStackResponseInfo.
  • NBT keys ench (id/lvl) and Items (container contents); Enchant::Type id order.
  • UI collection names inventory_items, container_items, barrel_items, shulker_box_items are used only after the screen confirms such a collection exists (in practice chests and shulker boxes both report container_items).
  • The section order (Shulker Boxes, Equipment, Items, Construction, Nature, unknown) is fixed in code; no item lists are maintained.

Follow-ups intentionally left out

Move matching / move all, hotbar-inclusive sorting, LaminaSort's own slot locking (vanilla item locks are honoured), auto-restock, background sorting, inventory search/highlighting, custom sort profiles, drop-all, an in-screen Sort button (a remappable key was chosen so the sorting core does not wait on UI injection), and any server-side component.

Which issues does this PR resolve?

None filed.

Checklist before merging

  • Your code builds clean without any errors or warnings
  • Your code follows LeviLamina C++ Style Guide
  • You have tested all functions
  • You have not used code without license
  • You have added statement for third-party code

🤖 Generated with Claude Code

amatouhake and others added 10 commits September 20, 2026 19:29
Same conventions as LaminaPeek: keep build/, bin/ and .xmake/ out of the
repository and normalise line endings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The planner works on synthetic slot states (count, max stack size, an
opaque mergeability group and an ordering key) and emits Move/Swap
operations plus the expected layout. Compatible partial stacks are
consolidated first (42+10 -> 52, 42+30 -> 64+8, minimal stack count),
then the region is arranged by key, group and count with empty slots
last. It has no Minecraft dependency; whether two stacks may merge is
supplied by the caller.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pressing the Sort key (default R, registered with the game's keyboard
settings) sorts the main 27 player inventory slots, or the hovered
ordinary storage container (chest, barrel, shulker box, ...).

- ScreenTracker observes the active container screen from the UI render
  callback and drops it on onLeave.
- StackClassifier assigns mergeability groups with the game's own
  ItemStackBase::isStackable(other) and orders kinds by the client's
  creative item registry (tab, group, entry), falling back to identifier
  order.
- SortSession snapshots the region, runs the planner and executes each
  step through ContainerManagerController::handlePlaceAmount /
  handleSwap, i.e. the same ItemStackRequest path the inventory UI uses,
  verifying the touched slots against the simulation before and after
  every step. Any disagreement, refused transfer or held cursor item
  aborts the remaining steps.
- ResponseLogger reports item stack requests the server rejects.
- Config file (sortKeyCode, sortContainers) and a --trace=y build with an
  immediately flushed trace.log.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Also run the planner tests in CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the literal Creative-tab ordering with a small semantic layer on
top of the game's own Creative registry:

- Sections: Shulker Boxes, Equipment, Items, Construction, Nature,
  unknown items, empty slots. Blocks are no longer split by tools. The
  section comes from the registry entry's Creative category; the only
  overrides are Shulker Boxes (always first) and food (Item::isFood ->
  Items, since Bedrock files most food under Equipment).
- Shulker Boxes: filled before empty (real "Items" contents), then custom
  name, then a content signature built from the virtually merged and
  sorted contents (independent of internal slot layout), then colour; the
  NBT hash only breaks remaining ties.
- Variants of one item: custom-named first (normalised, formatting codes
  stripped), then enchanted (canonical enchantment list: id order, level
  descending, longer list first), then plain; damage ascending so the
  better-conditioned item leads.
- SortKey moves to its own header with a field-by-field comparator;
  planned-layout logging shows the section and variant of each stack.

No sorting profiles or comparator configuration; the default is the
product. Transaction execution, consolidation, region selection and
safety verification are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Build against the 26.51.3 SDK (checked out from the upstream tag until the
LeviMC xmake-repo lists it). The only API change that touches LaminaSort
is ll::io::FileSink taking a RotatePolicy instead of an open mode since
26.51.2; the trace sink now passes RotatePolicy::disabled(), which keeps
the previous append-only behaviour. All other SDK headers LaminaSort
relies on are unchanged between 26.51.1 and 26.51.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The content signature of a Shulker Box only recorded each inner stack's
section, Creative index, identifier and aux value, so a box holding an
Efficiency V pickaxe, one holding a plain pickaxe and one holding a worn
pickaxe all produced the same signature. Inner stacks now get the same
ordering key an inventory item would get (custom name, enchantments,
damage, ... but never nested contents, so the description stays bounded
to one level); entries with equal keys are merged and sorted, so the
signature still does not depend on the internal slot layout.

Shulker Boxes are recognised by comparing the item's vtable with the
exported ShulkerBoxBlockItem vtable instead of an identifier suffix
match, which also covers the undyed box and excludes add-on items that
merely end in "shulker_box".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stacks carrying the vanilla item lock component were sorted like any
other, producing transfers the server rejects and reverts. The planner
now knows about fixed slots: it works on the movable slots as if they
were a contiguous region and maps the operations back, so no step ever
addresses a fixed slot, nothing is merged into or out of it, and the
rest of the region is sorted around it. applyOperation refuses any step
touching a fixed slot.

The classifier reads the lock mode through ItemLockHelper: lock_in_slot
is always fixed; lock_in_inventory may still change slots inside the
player inventory (as vanilla allows) and is left alone inside a
container region, where vanilla never lets a player put it. The session
logs how many slots stayed in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Typing an "r" into the Creative search field or an anvil name box fired
the Sort key handler, sorting the inventory mid-word. The key stays
bound; a new TextInputTracker hooks the game's own
ScreenView::_fireSelectedStateChangeEvent to know which text boxes of
the current screen are selected for typing, and the handler ignores the
press while any is. ScreenTracker now also remembers the ScreenView that
owns the current container controller and resets the tracker's
knowledge when the screen changes or is left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tics

State precisely that the server's verdict on each transfer arrives
asynchronously after all steps were issued, is only logged, and is
reverted by vanilla rather than fed back into the sort. Describe the
item-lock behaviour, the Sort key staying quiet while typing, the
class-based Shulker Box detection, the inner-item state in the content
signature and the LLVM requirement for building the SDK package.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@amatouhake
amatouhake merged commit e172b67 into main Sep 20, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant