Salt's Inventory Update

Desktop API Interactive Guide

A chaptered guide for modders adapting Minecraft container menus to Salt's movable desktop windows.

Salt Desktop frame, focus, placement, carried stack
Server Session menu, slots, data, payloads
Mod Definition rendering, widgets, custom input

Chapter 1

Start Here

Salt turns full-screen Minecraft inventory screens into desktop windows. Your mod does not create a new screen. It registers a window definition for the menu that Salt already captures or that your server handler opts into.

Mental Model

Salt owns the desktop shell: window frame, focus, movement, pinning, ghost previews, carried stack sync, hotbar and offhand handling, and normal slot click routing.

Your integration owns the inside of one window: its title, content size, widgets, real slot positions, fake item entries, custom input, tooltips, and any session-scoped payloads.

Salt handlesdesktop lifecycle, placement, item click protocol
Menu handlesserver inventory state, slot validation, data slots
Your code handleslayout, custom controls, payloads, optional state

Decision Path

  1. Does the feature use a real server menu? If not, keep it outside Salt or build a custom session model first.
  2. Are the displayed items real slots? Use Salt slot routing for real slots and virtual entries for fake items.
  3. Does the screen send custom packets? Convert screen-global packets to Salt session payloads.
  4. Does the texture include player inventory? Crop the machine area and do not draw duplicated player inventory.

What You Usually Build

public final class MyCompat {
    public static void initClient() {
        SaltsInventoryDesktopApi.registerClientWindow(MyMenus.MY_MENU, new MyWindow());
    }

    public static void initServer() {
        SaltsInventoryDesktopApi.registerServerWindow(MyMenus.MY_MENU, new MyServerWindow());
    }
}

A simple storage menu might only need real slot positions. A furnace-like machine usually also needs a cropped background, progress textures, and a recipe-book button. A terminal often needs virtual entries and custom payloads.

Chapter 2

Compatibility Triage

Triage decides whether a menu can become a Salt desktop window now, needs rework, or should stay on its original screen until the API grows.

Showstoppers

If any box applies, write the compat layer around that behavior first. Unknown modded menus should keep opening their original screens until the integration is explicit.

Pick A Pattern

Menu shape Use Avoid
Plain slot grid renderSlot, slotAt, fixed or storage-grid resize Drawing a duplicate player inventory
Machine or furnace-like Cropped background, texturelessSlot, progress textures Copying the whole vanilla screen texture
Terminal with fake entries DesktopVirtualItem, custom payloads, search widgets Returning DesktopSlotHit for non-slots
Screen with custom controls Widget helpers, menu buttons, typed payloads Global packets that do not name the Salt session

Rework Checklist

  1. Extract the machine/container UI from the old Screen renderer.
  2. Move client-only UI state into the desktop state object or synced data.
  3. Replace screen-global packets with Salt payloads for session-specific actions.
  4. Return real menu slot ids from slotAt.
  5. Test normal click, shift-click, drag distribution, double-click, carried stacks, ghost pin, and reopen.

Chapter 3

Registration

Client registration describes how a menu looks. Server registration decides whether Salt may capture that menu as a live desktop session.

Basic Registration

public static void initClient() {
    MenuType<MyMenu> menu = findMenu("other_mod:my_menu");
    if (menu != null) {
        SaltsInventoryDesktopApi.registerClientWindow(menu, new MyWindow());
    }
}

public static void initServer() {
    MenuType<MyMenu> menu = findMenu("other_mod:my_menu");
    if (menu != null) {
        SaltsInventoryDesktopApi.registerServerWindow(menu, new MyServerWindow());
    }
}

Register both sides for a captured modded menu. A client-only definition can exist, but it does not make an unknown server menu desktop-managed by itself.

Predicate Registration

SaltsInventoryDesktopApi.registerClientWindowPredicate(
    Identifier.fromNamespaceAndPath("my_mod", "dynamic_storage"),
    100,
    context -> context.sourceKey().startsWith("block:"),
    context -> new DynamicStorageWindow()
);

Use predicates when one MenuType has multiple layouts or when the source key decides which desktop window should be used.

Optional Compat

private static @Nullable MenuType<?> findMenu(String id) {
    Identifier key = Identifier.tryParse(id);
    return key == null ? null : BuiltInRegistries.MENU.getOptional(key).orElse(null);
}

Keep optional mod references guarded. Prefer registry ids, optional entrypoints, or small reflection helpers over hard class references in always-loaded code.

Chapter 4

Client Window

The client definition is the render and input adapter. It should describe the content area, not re-create Minecraft's full screen.

Lifecycle Hooks

Hook Use for
createStateSearch text, selected tab, scroll row, animation state.
titleWindow title based on the menu or local state.
defaultSizeFull Salt window size, including title bar and padding.
renderSlots, textures, text, widgets, virtual items.
slotAtReal menu slot hitboxes only.
saveLocalStateLocal UI state that should survive reopen.

Minimal Slot Window

public final class MyWindow implements DesktopWindowDefinition<MyMenu, MyWindow.State> {
    public static final class State {
        int scroll;
    }

    @Override
    public State createState(DesktopWindowSetupContext<MyMenu> context) {
        return new State();
    }

    @Override
    public DesktopWindowSize defaultSize(DesktopWindowSetupContext<MyMenu> context) {
        return DesktopWindowSize.of(8 + 9 * 18 + 8, 16 + 8 + 3 * 18 + 8);
    }

    @Override
    public void render(DesktopRenderContext<MyMenu, State> context) {
        int x = context.contentX();
        int y = context.contentY();
        for (int i = 0; i < 27; i++) {
            context.renderSlot(i, x + i % 9 * 18, y + i / 9 * 18);
        }
    }
}

Window Size vs Content Size

title bar padding contentX/Y, contentWidth/Height padding

Start drawing at context.contentX() and context.contentY(). Return a defaultSize large enough for title bar, padding, and content. In current examples that is usually 8 + contentWidth + 8 wide and 16 + 8 + contentHeight + 8 tall.

Chapter 5

Slots And Input

Normal item movement should flow through Salt's slot routing. Custom UI controls should consume input only when they actually handled it.

Real Slot Routing

@Override
public DesktopSlotHit slotAt(DesktopSlotContext<MyMenu, State> context, double mouseX, double mouseY) {
    int x = context.contentX();
    int y = context.contentY();

    DesktopSlotHit input = context.hitSlot(0, x + 56, y + 17, mouseX, mouseY);
    if (input != null) return input;

    return context.hitSlot(1, x + 116, y + 35, mouseX, mouseY);
}

Return the index into menu.slots, not visual order. If you think in container slot indexes, use containerSlot(...), containerSlotHit(...), and menuSlotId(...).

Slot Rendering Choices

Helper Use when
renderSlot(id, x, y)Salt should draw slot background, item, hover, and drag preview.
slot(slot, x, y)You already resolved the Slot object.
texturelessSlot(id, x, y)Your background already contains slot art.
slotBackground(x, y)You want Salt's slot art but need custom item rendering order.

Text Input

public static final class State {
    final DesktopTextBoxState search = new DesktopTextBoxState();
}

@Override
public void render(DesktopRenderContext<MyMenu, State> context) {
    DesktopWidgets.renderTextBox(context, context.state().search, context.contentX(), context.contentY(), 120);
}

@Override
public boolean mouseClicked(DesktopInputContext<MyMenu, State> context, MouseButtonEvent event, boolean doubleClick) {
    return DesktopWidgets.clickTextBox(context.state().search, event, context.contentX(), context.contentY(), 120);
}

@Override
public boolean wantsTextInput(DesktopWindowContext<MyMenu, State> context) {
    return DesktopWidgets.wantsTextInput(context.state().search);
}

Always implement wantsTextInput for focused text boxes. That tells Salt to suppress movement and normal UI hotkeys while the player is typing.

Virtual Items

List<DesktopVirtualItem> visible = entries.stream()
    .map(entry -> new DesktopVirtualItem(entry.stack(), entry.count()))
    .toList();

DesktopWidgets.renderVirtualItemGrid(context, visible, firstIndex, gridX, gridY, columns, rows);

int index = DesktopWidgets.virtualItemIndexAt(
    event.x(), event.y(), gridX, gridY, columns, rows, firstIndex, visible.size()
);
if (index >= 0) {
    context.sendPayload(PULL_CHANNEL, new PullPayload(index), PullPayload.CODEC);
    return true;
}

Virtual entries are not slots. Do not return DesktopSlotHit for them.

Chapter 6

Server And Payloads

Server handlers opt menus into capture and keep custom session behavior synchronized. Payloads are for custom actions that vanilla slot clicks and menu buttons cannot express.

Server Handler

public final class MyServerWindow implements DesktopServerWindowHandler<MyMenu, MyServerWindow.State> {
    public static final class State {
        long lastHash;
    }

    @Override
    public State createState(DesktopServerSessionContext<MyMenu, State> context) {
        return new State();
    }

    @Override
    public void tick(DesktopServerSessionContext<MyMenu, State> context) {
        context.broadcastChanges();
    }
}

Hidden ghost sessions still tick and validate through the desktop session manager. closed means the live session is really ending, not just becoming a ghost preview.

Typed And Raw Payloads

public record ModePayload(int mode) {
    public static final StreamCodec<RegistryFriendlyByteBuf, ModePayload> CODEC =
        StreamCodec.composite(ByteBufCodecs.INT, ModePayload::mode, ModePayload::new);
}

SaltsInventoryDesktopApi.registerServerPayload(MyMenus.MY_MENU, MY_CHANNEL, ModePayload.CODEC, (context, payload) -> {
    context.menu().setMode(payload.mode());
    context.broadcastChanges();
});

context.sendPayload(MY_CHANNEL, new ModePayload(2), ModePayload.CODEC);
SaltsInventoryDesktopApi.registerServerPayload(MyMenus.MY_MENU, MY_CHANNEL, context -> {
    byte[] data = context.data();
    int mode = decodeMode(data);
    context.menu().setMode(mode);
    context.broadcastChanges();
});

context.sendPayload(MY_CHANNEL, encodeMode(2));

Keep payloads small and session-scoped. Salt's networking layer is designed around a 32 KiB cap.

When To Use Payloads

  • Use normal slot clicks for real inventory slots.
  • Use sendMenuButton for vanilla-style menu buttons.
  • Use payloads for selected tabs, terminal actions, custom sort modes, fake entries, and special server commands.

Chapter 7

UI Patterns

These examples cover common layouts: cropped machine textures, recipe-book buttons, resizing, and desktop window behavior that Salt inherits automatically.

Cropped Furnace-Like Background

private static final Identifier MY_SCREEN_TEXTURE =
    Identifier.fromNamespaceAndPath("my_mod", "textures/gui/my_machine.png");
private static final int TEXTURE_WIDTH = 256;
private static final int TEXTURE_HEIGHT = 256;
private static final int MACHINE_WIDTH = 176;
private static final int MACHINE_HEIGHT = 84;

@Override
public DesktopWindowSize defaultSize(DesktopWindowSetupContext<MyMenu> context) {
    return DesktopWindowSize.of(8 + MACHINE_WIDTH + 8, 16 + 8 + MACHINE_HEIGHT + 8);
}

@Override
public void render(DesktopRenderContext<MyMenu, State> context) {
    int x = context.contentX();
    int y = context.contentY();

    context.texture(MY_SCREEN_TEXTURE, x, y, 0, 0,
        MACHINE_WIDTH, MACHINE_HEIGHT,
        MACHINE_WIDTH, MACHINE_HEIGHT,
        TEXTURE_WIDTH, TEXTURE_HEIGHT);

    context.texturelessSlot(0, x + 56, y + 17);
    context.texturelessSlot(1, x + 56, y + 53);
    context.texturelessSlot(2, x + 116, y + 35);
}

Use texturelessSlot only when your cropped texture already has slot backgrounds. Otherwise use renderSlot or draw slotBackground first.

Recipe Book

@Override
public RecipeBookComponent<?> createRecipeBook(DesktopWindowContext<MyMenu, State> context) {
    return MyClientReflect.createRecipeBook(context.menu());
}

@Override
public boolean mouseClicked(DesktopInputContext<MyMenu, State> context, MouseButtonEvent event, boolean doubleClick) {
    if (recipeButton.contains(event.x(), event.y())) {
        context.toggleRecipeBook();
        return true;
    }
    return false;
}

On 1.20.1 and 1.21.1, return raw RecipeBookComponent instead of RecipeBookComponent<?>.

Resize And Snap

@Override
public DesktopResizePolicy resizePolicy(DesktopWindowContext<MyMenu, State> context) {
    return DesktopResizePolicy.STORAGE_GRID;
}

@Override
public DesktopWindowSize snapSize(DesktopWindowContext<MyMenu, State> context) {
    int columns = Math.max(1, context.contentWidth() / 18);
    int rows = Math.max(1, context.contentHeight() / 18);
    return DesktopWindowSize.of(8 + columns * 18 + 8, 16 + 8 + rows * 18 + 8);
}

Use fixed windows for functional machine UIs. Use storage-grid resizing only when the window can add or remove visible rows or columns cleanly.

Inherited Desktop Behavior

lock pin ghost pin placement carried stack hotbar shift-click tooltips

Do not rebuild these features in your definition. Add hooks such as ghosted() only for local visual state or preview-specific effects.

Chapter 8

Troubleshooting

Most integration failures come from missing server support, wrong slot ids, global packets, or rendering the old screen unchanged.

Common Failure Modes

Window does not open in Salt

Confirm the menu type was found, the client definition registered, and the server handler or payload handler registered for unknown modded menus. Unknown menus fall back to their original screen.

Clicking a slot moves the wrong item

Check that slotAt returns the menu slot id. Container slot indexes and visual slot order are not always the same thing.

Payload never reaches the server

Verify the payload channel id, codec/raw encoding, server registration, and that the action belongs to the same menu type as the captured session.

Player inventory appears twice

Crop the machine portion of the texture and remove player inventory slots from the custom render path. Salt already handles player inventory and hotbar interaction.

Typing also moves the player

Implement wantsTextInput and return true while a text box is focused.

Debug Checklist

  1. Log the menu registry id and slot count when registering.
  2. Render temporary labels for menu slot ids while aligning hitboxes.
  3. Test one slot click before adding custom widgets.
  4. Test one payload before adding complex state syncing.
  5. Compare behavior with the original screen for shift-click and result slots.

Chapter 9

Reference

Keep this chapter nearby once the integration works. It summarizes the API surface and version differences.

Context Quick Reference

DesktopWindowContext

menu, sessionId, state, contentX/Y, containerSlot, menuSlotId

DesktopRenderContext

fill, text, texture, sprite, slot, texturelessSlot, virtualItem

DesktopInputContext

sendMenuButton, clickSlot, quickMoveSlot, sendPayload, toggleRecipeBook

DesktopWidgets

Text boxes, icon buttons, text buttons, scrollbars, dropdowns, virtual item grids.

Version Summary

Band Important differences
26.1.2 and 26.2Identifier, ContainerInput, typed payload helpers.
1.21.11Identifier, ClickType, typed payload helpers.
1.21.1ResourceLocation, Salt compatibility input package, raw RecipeBookComponent.
1.20.1ResourceLocation, raw byte payloads only.