Skip to content

perf: synchronize terminal repository client-side - #151

Merged
swear01 merged 9 commits into
mainfrom
perf/terminal-repository-sync
Aug 22, 2026
Merged

swear01 merged 9 commits into
mainfrom
perf/terminal-repository-sync

Conversation

@swear01

@swear01 swear01 commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Summary

  • replace server-paginated terminal scrolling with a client-side repository and virtual display slots
  • synchronize full, chunked, revisioned, and incremental typed-resource updates with serial-based server validation
  • keep Crafting selection, typed container transfer, stale-cache resync, and server-authoritative transactions fail-closed
  • move normal terminal filtering to the client and preserve server-side recipe validation

Verification

  • ./gradlew build --no-daemon
  • ./gradlew runGameTestServer --no-daemon
  • ./gradlew runRecipeAddonGameTestServer --no-daemon
  • ./gradlew runCompatFixtureGameTestServers --no-daemon
  • ./gradlew runPneumaticCraftGameTestServer --no-daemon
  • ./gradlew runCompatibilityMatrixGameTestServer --no-daemon
  • ./gradlew runCompatibilityMatrixGameTestServer -PterminalScaleTypes=30000 --no-daemon
  • PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover scripts
  • ./gradlew runData --no-daemon

@gemini-code-assist

Copy link
Copy Markdown

Warning

You have reached your daily quota limit. Please wait up to 24 hours and I will start processing your requests again!

@swear-review swear-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swear Review — Full PR review · batch 1/1 · OCR v1.9.0 · deepseek-v4-flash

var player = ctx.player();
if (player != null && player.containerMenu instanceof StorageTerminalMenu menu
&& menu.containerId == packet.containerId()) {
menu.sendFullRepository();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This handler responds to every TerminalRepositoryResyncPacket by immediately sending a full repository snapshot via menu.sendFullRepository() (which forces sendRepositoryUpdate to serialize the whole repository into chunked packets), with no rate limiting or cooldown. The packet only carries a containerId and the guard here only verifies the player has that menu open, so any client with the terminal open can spam this packet to repeatedly trigger full-repository serialization/transmission, causing avoidable server CPU and network load. Consider rate-limiting resync responses per player (e.g., a minimum interval or in-flight flag) or verifying the client's reported state is actually stale before resending a full snapshot.

medium · security

— Swear Review

Comment on lines +3692 to +3697
if (player.level().isClientSide()
|| player.isSpectator()
|| !stillValid(player)
|| packet.containerId() != containerId
|| packet.quickMove()
|| !getCarried().isEmpty()) return false;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On the CRAFTABLE page, packet.quickMove() is treated as an invalid action and the method returns false without delegating to super.handleRepositoryAction, unlike the STORAGE branch which explicitly forwards quick-move requests to the superclass. The client screen sends quick-move packets for any page where the repository is active (slotClicked sends TerminalRepositoryActionPacket for both PICKUP and QUICK_MOVE), so shift+clicking a craftable item in the repository will be silently ignored. If quick-move is intentionally disabled on the craftable page (to keep click-as-select semantics), make that explicit; otherwise shift+click extraction is silently broken compared to the STORAGE page.

medium · bug

— Swear Review

match.selectionOutputKey(level, output).orElse(null);
if (key == null || output.isEmpty() || !getResourceView().matches(key)
|| !matchesCraftableFilter(key, output, query, level)) continue;
if (key == null || output.isEmpty()) continue;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The craftable candidate loops previously filtered recipes with !getResourceView().matches(key) and !matchesCraftableFilter(...) before invoking the expensive computeCraftableStatus preview simulation (and recordNextCraftableThreshold). Those early-exit guards were removed from all three loops, and filtering is now deferred entirely to visibleCraftableDisplayStacks. As a result, the full status simulation runs for every recipe candidate even when an active search filter would have excluded most of them. On large recipe catalogs this can noticeably increase craftable-refresh latency. Consider retaining an inexpensive pre-filter (e.g., matching by the output key/name only) while still keeping the unfiltered displayStacks list for the repository, or documenting the trade-off if the extra cost is acceptable.

medium · performance

— Swear Review

totalItemTypes = displayStacks.size();
List<ItemStack> visibleStacks = visibleCraftableDisplayStacks(
displayStacks, core.getLevel());
totalItemTypes = visibleStacks.size();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

totalItemTypes is still used as the scroll-range basis on the server side (getMaxScrollOffset/rowAlignedScrollOffset and the tests in CraftingTests/TerminalFlowTests). Changing it to the filtered visible count while setRepositoryStacks stores the unfiltered list means the server-side scrollbar range now only reflects the filtered subset, whereas the client repository (totalEntries()) reflects the unfiltered list. When a search filter is active these two scroll ranges can disagree, which can cause the scroll offset clamp on the server to differ from the client's expectations. If the scroll clamping should still be based on the full item count, compute totalItemTypes from displayStacks and track the filtered count separately.

low · maintainability

— Swear Review

Comment on lines +106 to +109
int index = scrollOffset + visibleIndex;
if (visibleIndex < 0 || visibleIndex >= visibleRows * StorageTerminalMenu.DISPLAY_COLS
|| index >= visibleEntries().size()) return -1;
return visibleEntries().get(index).serial();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

index = scrollOffset + visibleIndex uses int arithmetic. maxScrollOffset() can legally return values up to the largest 9-aligned int (near Integer.MAX_VALUE), so adding a small visibleIndex can overflow to a negative number. The subsequent guard only checks index >= visibleEntries().size(), which a negative index always passes, so visibleEntries().get(index) would throw IndexOutOfBoundsException. Use long arithmetic (and cast the guard comparison) so the bounds check also rejects overflowed values.

Suggested change
int index = scrollOffset + visibleIndex;
if (visibleIndex < 0 || visibleIndex >= visibleRows * StorageTerminalMenu.DISPLAY_COLS
|| index >= visibleEntries().size()) return -1;
return visibleEntries().get(index).serial();
long index = (long) scrollOffset + visibleIndex;
if (visibleIndex < 0 || visibleIndex >= (long) visibleRows * StorageTerminalMenu.DISPLAY_COLS
|| index >= visibleEntries().size()) return -1;
return visibleEntries().get((int) index).serial();

low · bug

— Swear Review

Comment on lines +36 to +39
int entryCount = buf.readVarInt();
if (entryCount < 0 || entryCount > MAX_ENTRIES_PER_PACKET) {
throw new IllegalArgumentException("Too many terminal repository entries");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The combined check entryCount < 0 || entryCount > MAX_ENTRIES_PER_PACKET always throws "Too many terminal repository entries", but buf.readVarInt() can legitimately decode to a negative value for a malformed/oversized packet. In that case the error message is misleading and makes debugging harder. Consider separating the two cases so a negative count reports a distinct message.

Suggested change
int entryCount = buf.readVarInt();
if (entryCount < 0 || entryCount > MAX_ENTRIES_PER_PACKET) {
throw new IllegalArgumentException("Too many terminal repository entries");
}
int entryCount = buf.readVarInt();
if (entryCount < 0) {
throw new IllegalArgumentException("Negative terminal repository entry count");
}
if (entryCount > MAX_ENTRIES_PER_PACKET) {
throw new IllegalArgumentException("Too many terminal repository entries");
}

low · maintainability

— Swear Review

@swear-review

swear-review Bot commented Aug 21, 2026 •

Copy link
Copy Markdown

Swear Review

Mode: Full PR
Model: deepseek-v4-flash
OCR: v1.9.0
Commit: b04be1a

Findings:

  • none

0 findings total
0 inline comments

Status: ✅ Completed

@swear-review swear-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swear Review — Full PR review · batch 1/1 · OCR v1.9.0 · deepseek-v4-flash

Comment on lines +34 to +35
int chunkIndex = buf.readVarInt();
int chunkCount = buf.readVarInt();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

chunkIndex/chunkCount are read here but only validated later inside the compact constructor, i.e., after up to MAX_ENTRIES_PER_PACKET entries have already been decoded and allocated. For a network-facing decoder, validate the chunk header immediately after reading it so a malformed packet (e.g., negative chunkIndex or chunkCount <= 0) is rejected before any entry parsing work is done.

Suggested change
int chunkIndex = buf.readVarInt();
int chunkCount = buf.readVarInt();
int chunkIndex = buf.readVarInt();
int chunkCount = buf.readVarInt();
if (chunkCount <= 0 || chunkIndex < 0 || chunkIndex >= chunkCount) {
throw new IllegalArgumentException("Invalid terminal repository chunk");
}

low · other

— Swear Review

Comment on lines +79 to +82
entries = List.copyOf(entries);
if (entries.size() > MAX_ENTRIES_PER_PACKET) {
throw new IllegalArgumentException("Too many terminal repository entries");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

List.copyOf(entries) will throw an opaque NullPointerException if entries is null, and it runs before the size-cap check, so an oversized list is fully copied before being discarded. Validate null and size first, then make the defensive copy.

Suggested change
entries = List.copyOf(entries);
if (entries.size() > MAX_ENTRIES_PER_PACKET) {
throw new IllegalArgumentException("Too many terminal repository entries");
}
if (entries == null) {
throw new IllegalArgumentException("Missing terminal repository entries");
}
if (entries.size() > MAX_ENTRIES_PER_PACKET) {
throw new IllegalArgumentException("Too many terminal repository entries");
}
entries = List.copyOf(entries);

low · maintainability

— Swear Review

Comment on lines +72 to +75
@Override
public boolean mayPickup(Player player) {
return delegate.mayPickup(player);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This wrapper presents a read-only remote slot, yet it delegates the "interactive" behaviors to the delegate slot: mayPickup() returns delegate.mayPickup(player) (the delegate GhostSlot.mayPickup() always returns true), and allowModification()/onTake()/onQuickCraft() are forwarded to the delegate. Combined with the no-op set() and remove() (which returns ItemStack.EMPTY), the slot advertises itself as pickable/modifiable but silently drops any mutation attempt. The screen only intercepts PICKUP/QUICK_MOVE click types; other paths such as THROW (pressing Q over a slot), SWAP (number keys 1-9) and QUICK_CRAFT (drag) still route through the vanilla clicked() flow on the client, which calls remove()/set() on this slot. The result is inconsistent client behavior (e.g., Q-throw does nothing because remove returns EMPTY while the item still appears present, drag operations accumulate empty stacks) and a risk of client/server desync. Recommendation: make the wrapper fully consistent with a read-only slot — override mayPickup to return false, and make onTake/onQuickCraft no-ops (or remove the delegate forwarding) — so that every interaction goes through the packet-based action handler rather than the vanilla mutation path.

Suggested change
@Override
public boolean mayPickup(Player player) {
return delegate.mayPickup(player);
}
@Override
public boolean mayPickup(Player player) {
return false;
}

medium · bug

— Swear Review

continue;
}
next.put(key, display.copy());
if (old == null || !ItemStack.isSameItemSameComponents(old, display)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ItemStack.isSameItemSameComponents() compares item type and components but intentionally ignores the stack count. However, in this codebase the display stacks for a resource are created with setCount(1) and carry their real amounts in the auto_storage:terminal_display_amount custom-data tag (see TerminalDisplayStack.create/amount, and StorageCoreBlockEntity.displayFor). That tag is a component, so isSameItemSameComponents correctly detects an amount-only change (e.g. 1 -> 64). So this is not a count bug. However, there IS a real issue here for non-item views: index() falls back to StorageResourceKey.item(TerminalDisplayStack.strip(display), registries) only when TerminalResourceDisplay.key(display) is absent. strip() removes the amount tags but NOT the auto_storage:terminal_resource tag, so typed displays keep their real key. For plain ITEM view displays, stripping also removes the amount tag and the item key remains unchanged — which is correct since serial is keyed to the resource, not the amount. No count-related defect exists in these two comparisons.

low · other

— Swear Review

import java.util.Map;

final class TerminalRepositoryServer {
private final Map<StorageResourceKey, Long> serials = new HashMap<>();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serials is populated via serials.computeIfAbsent(key, ignored -> nextSerial++) and entries are never removed. However, this map is only used to assign each distinct resource key a stable serial, and the class is created per-menu-instance (new TerminalRepositoryServer() in StorageTerminalMenu). As long as the menu/container stays open, keys for resources that leave the terminal remain mapped, but the terminal's distinct resource key space is bounded by the storage core's type capacity; the serials map cannot grow beyond the total number of distinct keys ever seen. This is bounded per open container and is released when the menu closes, so it is not an unbounded memory leak in practice. Still, a removal path (or bounding the map to keys present in previous) would be a worthwhile hardening.

low · other

— Swear Review

Comment on lines +150 to +152
if (indexed.put(key, display.copy()) != null) {
throw new IllegalStateException("Duplicate terminal repository resource " + key);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

index() throws IllegalStateException on a duplicate key. The current callers pass core.getTerminalDisplayStacks(...) (per-key aggregated) or buildCraftableDisplayStacks output (built from a LinkedHashMap keyed by StorageResourceKey, so unique). The fallback key derivation via StorageResourceKey.item(strip(display), registries) also maps a display to the same key for identical resources. So duplicates should not occur from existing callers, and the fail-fast guard is reasonable defensive validation. No change strictly required.

low · other

— Swear Review

Comment on lines +41 to +45
List<TerminalRepositoryUpdatePacket> updateChanges(
int containerId,
Map<StorageResourceKey, ItemStack> changed,
java.util.Set<StorageResourceKey> removed
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateChanges() does not consult fullPending, so incremental packets can be emitted while a full snapshot is pending. At the actual call sites (sendRepositoryUpdate in StorageTerminalMenu), when repositoryFullRequested is true and repositoryFullListPending is false, updateChanges is deliberately called first to flush pending deltas and then fullSnapshot is sent in the same tick, so the client always receives the full snapshot afterward. Given the current call sequence, no state corruption occurs. If future callers invoke updateChanges directly between requestFull() and update(), this ordering guarantee could be lost, so it is worth documenting.

low · other

— Swear Review

Comment on lines +15 to +17
private long nextSerial = 1;
private long revision;
private boolean fullPending = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This class is instantiated once per menu (final TerminalRepositoryServer repositoryServer = new TerminalRepositoryServer(); in StorageTerminalMenu) and all callers (sendRepositoryUpdate, repositoryKey, repositoryDisplay, handleRepositoryAction, etc.) execute on the Minecraft server thread. The repositoryServer is never accessed from a client thread or async worker. Within the server tick, menu callbacks are serialized, so the check-then-act on fullPending and the nextSerial++ increment are effectively single-threaded. No thread-safety defect can be demonstrated from the call sites; the object is confined to the server thread.

low · other

— Swear Review

Comment on lines +160 to +162
Map<StorageResourceKey, ItemStack> copy = new LinkedHashMap<>();
source.forEach((key, stack) -> copy.put(key, stack.copy()));
return Map.copyOf(copy);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

copy() builds a LinkedHashMap then wraps it with Map.copyOf(), which does not preserve iteration order. However, previous is only used for lookups and diffing in changedEntries/updateChanges, and the client re-sorts entries in visibleEntries() (see TerminalClientRepository), so ordering of the server-side map has no user-visible effect. Map.copyOf also benefits from iteration stability. This is a non-issue in practice.

low · other

— Swear Review

Comment on lines +87 to +92
StorageResourceKey keyFor(long serial) {
for (Map.Entry<StorageResourceKey, Long> entry : serials.entrySet()) {
if (entry.getValue() == serial) return entry.getKey();
}
return null;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

keyFor() linearly scans serials on every call. It is invoked from repositoryKey(serial) / repositoryDisplay(serial), which are only called when a client action packet (click/transfer) is handled, i.e. infrequently per player interaction, not per display frame. With a terminal's typical distinct-resource counts this O(n) scan is negligible. A reverse serial->key map would be a micro-optimization; not required.

low · performance

— Swear Review

@swear-review swear-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swear Review — Full PR review · batch 1/1 · OCR v1.9.0 · deepseek-v4-flash

Comment thread scripts/test_static_regressions.py Outdated
self.assertNotIn("while (delta < 0)", screen)
self.assertNotIn("while (delta >= 9)", screen)

def test_terminal_scroll_uses_client_repository_without_server_packet(self):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new test method largely duplicates the assertions already covered by test_terminal_scrollbar_uses_client_repository_without_server_scroll_packets (e.g., TerminalRepositoryUpdatePacket in the entrypoint, and the negative TerminalScrollPacket checks). The only marginally unique assertion is TerminalClientRepository in the menu source. Keeping two near-identical scroll-regression tests doubles maintenance burden and produces confusing duplicate failures if the client-repository contract regresses. Consider removing this method or merging its unique assertion into the comprehensive test above.

low · maintainability

— Swear Review

Comment on lines +44 to +45
buf.writeVarInt(packet.expectedView().ordinal());
buf.writeVarInt(packet.direction().ordinal());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The wire format serializes the enums by their ordinal() (and decodes back via values()[id] in requireById/byId). This is internally consistent today, but it makes the network protocol silently change if any constant is reordered or inserted in the middle of TerminalResourceView or TerminalContainerTransferDirection in a future update — old and new clients/servers would interpret the same bytes as different views/directions without any encode/decode error, potentially causing wrong container transfers. Since this is a playToServer packet, consider either giving the enums explicit stable wire IDs (and mapping them in the codec) or at minimum ensuring any enum reordering forces a protocol version bump.

low · maintainability

— Swear Review

Comment on lines +50 to +52
if (containerId < 0 || stateId < 0 || serial <= 0) {
throw new IllegalArgumentException("Invalid terminal repository transfer");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This constructor validation is stricter than its only producer. In StorageTerminalScreen.mouseClicked, a WITHDRAW packet is sent for an empty carried stack when button == 0 on a non-item view (the !menu.getCarried().isEmpty() guard only applies to button == 1, i.e. DEPOSIT). The server rejects that packet in handleRepositoryContainerTransfer (withdraw path returns false), so it is not a functional bug, but the sender still allows an obviously invalid direction/hold combination that the receiver must defensively ignore. If a client were ever allowed to send this with an empty carry, the mismatch between the client-side construction (no validation) and the server-side assumption (getCarried().isEmpty() is rejected) could lead to a silent no-op rather than a meaningful error. Consider validating on the client side too, or documenting why an empty-carry WITHDRAW is intentionally sent.

low · bug

— Swear Review

Comment on lines +35 to +37
long serial() {
return repository.serialAt(visibleIndex, visibleRows);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

serial() is dead code: a codebase-wide search for \.serial() (and for TerminalRepositorySlot) shows this method is never invoked. The only call site (StorageTerminalScreen) accesses repository.serialAt(visibleIndex, visibleRows) directly in both slotClicked and mouseClicked, bypassing this wrapper. Consider removing the unused method or having the screen use it for consistency.

low · maintainability

— Swear Review

int chunkCount,
List<TerminalRepositoryEntry> entries
) implements CustomPacketPayload {
static final int MAX_ENTRIES_PER_PACKET = 512;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 512-entry cap bounds chunking by entry count only, not by serialized byte size. Each entry can carry a full ItemStack (including its components/NBT) plus an optional resource key with a variant CompoundTag, so a chunk of 512 large/complex entries can exceed Minecraft's maximum network packet size (2^21 bytes). When that happens the connection is forcibly closed and the terminal repository sync fails. Consider bounding chunks by estimated encoded size (e.g., in TerminalRepositoryServer.chunk) rather than relying solely on the fixed entry-count cap, or at least document the size assumption behind this constant.

medium · bug

— Swear Review

@swear-review swear-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swear Review — Full PR review · batch 1/1 · OCR v1.9.0 · deepseek-v4-flash

Comment on lines +27 to +31
TerminalContainerTransferDirection direction =
TerminalContainerTransferDirection.byWireId(buf.readVarInt());
if (direction == null) {
throw new IllegalArgumentException("Unknown terminal container transfer direction");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deserialization error handling is inconsistent between the two enum lookups: TerminalResourceView.requireWireId throws IllegalArgumentException for unknown wire IDs, whereas TerminalContainerTransferDirection.byWireId returns null and is explicitly null-checked here. Since the compact constructor's Objects.requireNonNull(direction) would throw a generic NullPointerException for an invalid direction, the explicit check here is somewhat redundant; conversely, relying on requireWireId's exception is fine. For consistency and clearer failures, consider having byWireId throw for unknown IDs (or document the contract), or drop the redundant null check and let the constructor reject it uniformly.

low · maintainability

— Swear Review

Comment on lines +99 to +106
RegistryFriendlyByteBuf buf = new RegistryFriendlyByteBuf(
Unpooled.buffer(), registries);
try {
writeEntry(buf, entry);
return buf.readableBytes();
} finally {
buf.release();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

encodedEntrySize is called once per entry inside the chunking loop in TerminalRepositoryServer.chunk(), and each invocation allocates and releases a fresh Unpooled.buffer()/RegistryFriendlyByteBuf just to measure the serialized size of a single entry. For a large repository snapshot (hundreds/thousands of entries), this creates avoidable buffer allocation and GC pressure on the server thread. Consider passing a single reusable scratch buffer into the loop (e.g., buf.clear() before each entry) or computing the size without a full serialization pass.

Suggested change
RegistryFriendlyByteBuf buf = new RegistryFriendlyByteBuf(
Unpooled.buffer(), registries);
try {
writeEntry(buf, entry);
return buf.readableBytes();
} finally {
buf.release();
}
RegistryFriendlyByteBuf buf = new RegistryFriendlyByteBuf(
Unpooled.buffer(), registries);
try {
writeEntry(buf, entry);
return buf.readableBytes();
} finally {
buf.release();
}

low · performance

— Swear Review

@swear-review swear-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swear Review — Full PR review · batch 1/1 · OCR v1.9.0 · deepseek-v4-flash

Comment on lines +862 to +868
TerminalClientRepository repository = menu.clientRepository();
long serial = hoveredSlot instanceof TerminalRepositorySlot repositorySlot
? repositorySlot.serial()
: repository == null
? -1 : repository.serialAt(hoveredSlot.index, visibleRows);
if (serial <= 0) return true;
minecraft.getConnection().send(new TerminalRepositoryContainerTransferPacket(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the held-container transfer branch of mouseClicked(), the serial is computed as -1 whenever the client repository is null, and the if (serial <= 0) return true immediately consumes the click. This silently disables held-container transfers whenever the repository isn't available (e.g., before the first repository sync, after a failed sync that triggered recovery, or during non-item pages). The old behavior always sent TerminalHeldContainerTransferPacket. Since the legacy packet and its server handler are still registered (AutoStorage/StorageTerminalMenu), the code should fall back to sending the legacy packet when the repository is unavailable instead of dropping the click.

high · bug

— Swear Review

Comment on lines +863 to +866
long serial = hoveredSlot instanceof TerminalRepositorySlot repositorySlot
? repositorySlot.serial()
: repository == null
? -1 : repository.serialAt(hoveredSlot.index, visibleRows);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In mouseClicked(), the fallback repository.serialAt(hoveredSlot.index, visibleRows) passes the full menu slot index. TerminalRepositorySlot instances are only installed for menuIndex < DISPLAY_SLOTS (replaceSlot), so when hoveredSlot is not a TerminalRepositorySlot, hoveredSlot.index can be >= DISPLAY_SLOTS (player inventory) and thus outside the visible grid. serialAt expects a 0-based visible index and guards on visibleIndex >= visibleRows * DISPLAY_COLS; using hoveredSlot.index produces the wrong entry or -1, so the wrong container transfer (or none) is triggered for a hovered slot that isn't one of the replaced repository slots. Since the guard at the top of this branch already limits hoveredSlot.index to the visible grid, the non-repository-slot fallback should be removed (a matching TerminalRepositorySlot is guaranteed within that range), or the visible index should be derived explicitly.

medium · bug

— Swear Review

preferences.sortMode(),
preferences.sortOrder(),
preferences.resourceView());
repository.setScrollOffset(repository.getScrollOffset(visibleRows), visibleRows);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateClientRepositoryView() calls repository.setScrollOffset(repository.getScrollOffset(visibleRows), visibleRows), which reads the current (already clamped/aligned) offset and writes it back unchanged. This is a no-op that obscures intent; drop the call or add a comment explaining what re-clamping is intended to achieve.

low · maintainability

— Swear Review

Comment on lines +217 to +221
repository.setView(
searchBox == null ? "" : searchBox.getValue(),
preferences.sortMode(),
preferences.sortOrder(),
preferences.resourceView());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateClientRepositoryView() is invoked from init(), renderBg() (every frame while the item view is active) and containerTick() (every tick), and it always calls repository.setView(...). Inside setView, when the search text is non-empty, the dirty flag is set and the full entry list is re-filtered/re-sorted on the next visibleEntries() access, which is then triggered by maxScrollOffset()/totalEntries() in the same render frame. Since the search string only changes when the user types, repeated renders with the same text keep the cached visible list (setView early-returns when nothing changed). The only repeated cost is the cheap string equality check. No performance problem is evident here; no comment issued.

low · other

— Swear Review

Comment on lines +53 to +56
@Override
public ItemStack remove(int amount) {
return ItemStack.EMPTY;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All terminal slot interactions are routed to the server via TerminalRepositoryActionPacket / TerminalRepositoryContainerTransferPacket, and the server-side StorageTerminalMenu.clicked() early-returns for display-slot indices so remove() on the client slot is never a valid withdrawal path. The no-op remove()/set()/mayPickup() design is consistent with the new architecture, so no issue here.

low · other

— Swear Review

Comment on lines +77 to +80
@Override
public int getMaxStackSize() {
return delegate.getMaxStackSize();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getMaxStackSize()/getMaxStackSize(ItemStack) forward to the delegate slot, which for these repository slots is a GhostSlot bound to displayInventory (whose container returns Integer.MAX_VALUE from getMaxStackSize). As a result every repository slot reports Integer.MAX_VALUE regardless of the item it actually displays (e.g. 1 for a non-stackable item). The delegate here is just a positional placeholder, not the slot holding the displayed item, so the value is semantically wrong and can mislead vanilla/mod logic that derives transfer amounts or drag-split math from slot.getMaxStackSize(). Consider deriving it from the displayed item, e.g. getItem().getMaxStackSize().

Suggested change
@Override
public int getMaxStackSize() {
return delegate.getMaxStackSize();
}
@Override
public int getMaxStackSize() {
return getItem().getMaxStackSize();
}

medium · bug

— Swear Review

Comment on lines +102 to +105
@Override
public boolean isFake() {
return delegate.isFake();
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isFake() forwards to delegate.isFake(). The delegate is a GhostSlot over displayInventory, which is a real (non-fake) slot, so this virtual slot reports isFake() == false even though its displayed item does not actually live in any container (it is a pure repository projection). Client logic and mod integrations that use isFake() to decide whether a slot is safe to interact with (creative clone, pickup simulation, etc.) will treat it as a real inventory slot. Since this slot intentionally rejects all direct mutations, it should report true here.

Suggested change
@Override
public boolean isFake() {
return delegate.isFake();
}
@Override
public boolean isFake() {
return true;
}

low · bug

— Swear Review

@swear-review swear-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swear Review — Full PR review · batch 1/1 · OCR v1.9.0 · deepseek-v4-flash

Comment on lines +6 to +12
ITEM(0),
FLUID(1),
ENERGY(2),
GAS(3),
STATION_WORK(4),
OTHER(5),
ALL(6);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The explicit wireId values exactly duplicate the enum ordinals, but only the new transfer packets (TerminalHeldContainerTransferPacket/TerminalRepositoryContainerTransferPacket) use wireId. All other wire encodings still rely on ordinal order: StorageTerminalMenu's data-slot sync uses resourceView.ordinal()/byId(v), and TerminalPreferences.write/read serializes resourceView.ordinal(). If the enum constants are ever reordered (e.g. to change the UI cycle order), the wireId-based packets would decode to a different view than the ordinal-based data-slot/preferences sync, causing the server's packet.expectedView() != resourceView guard to reject legitimate transfers silently. Consider migrating all wire encodings to wireId, or add a SelfTest assertion that wireId() == ordinal() (or that the values match the intended wire order) so the two schemes cannot diverge unnoticed.

low · maintainability

— Swear Review

@swear-review swear-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swear Review — Full PR review · batch 1/1 · OCR v1.9.0 · deepseek-v4-flash

Comment on lines +1072 to 1076
private void setScrollTarget(int target) {
TerminalClientRepository repository = menu.clientRepository();
if (repository == null) return;
repository.setScrollOffset(target, visibleRows);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legacy (non-repository) scrolling is broken. The old sendScrollTarget(...) sent a TerminalScrollPacket to the server, but it has been replaced by setScrollTarget(...), which only calls repository.setScrollOffset(...) and returns immediately when menu.clientRepository() is null (line 1074). All callers (scrollbar press at line 889, drag at 927, wheel step at 943, and containerTick at 993) now route through setScrollTarget, so in the null-repository fallback path there is no way to send the new scroll position to the server. Since the server menu still owns scrollOffset (synchronized via data slots), scrollbar clicks/drags/wheel scrolling will be completely non-functional on the legacy path. Consider sending the TerminalScrollPacket (or an equivalent scroll message) to the server when the repository is null.

high · bug

— Swear Review

Comment on lines +831 to +841
long serial = slot instanceof TerminalRepositorySlot repositorySlot
? repositorySlot.serial()
: repository.serialAt(slotId, visibleRows);
if (serial > 0 && minecraft != null && minecraft.getConnection() != null) {
minecraft.getConnection().send(new TerminalRepositoryActionPacket(
menu.containerId,
serial,
mouseButton,
clickType == ClickType.QUICK_MOVE));
}
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This new override swallows clicks on all display slots (0 <= slotId < visibleRows * DISPLAY_COLS) for PICKUP/QUICK_MOVE in repository mode. However, repositionSlots()/replaceSlot(...) converts every slot in the grid (menuIndex < DISPLAY_SLOTS, i.e. up to 18 rows) into a TerminalRepositorySlot, while menu.clicked(...) still intercepts clicks in the display-slot range. When the slot is a TerminalRepositorySlot whose serial resolves to -1 (e.g. empty row due to row-aligned scrolling, or no entry at that index), the click is dropped (line 831-842). Additionally, the first block only forwards serial-based actions; because TerminalRepositorySlot.set/remove/mayPlace are all no-ops returning empty/false, double-click pickup (ClickType.PICKUP with button 0 on an already picked-up stack) and other non-PICKUP/QUICK_MOVE flows are no longer possible in repository mode. Note also that the non-item resource branch in mouseClicked returns true when hoveredSlot is not a TerminalRepositorySlot, silently consuming the click; with the full grid replaced by repository slots, this affects every display slot in non-item views, not just the currently visible rows. Please confirm clicks on empty/off-grid display slots are intentionally dropped and that the server-side clicked(...) handler (which reads slot.getItem() from a client-populated slot) cannot be reached in this mode.

medium · bug

— Swear Review

Comment on lines +305 to +306
() -> menuIndex < visibleRows * StorageTerminalMenu.DISPLAY_COLS
&& delegate.isActive());

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isActive() predicate captures visibleRows by value at slot-creation time. Slots are only (re)built in init() -> repositionSlots(), while visibleRows is also mutated server-side via applySettings(...) (in StorageTerminalMenu) with no client-side slot rebuild. If the window is resized or the row count otherwise changes without re-running init()/repositionSlots(), slots created with the old visibleRows will keep the stale predicate, so either the active range is mis-computed or clicks beyond the current visible grid are routed. Prefer referencing the current field (e.g. menuIndex < this.visibleRows * DISPLAY_COLS) or re-deriving it from the geometry at render time.

medium · bug

— Swear Review

Comment on lines 1025 to +1026
private void sendSearchPacket() {
if (menu.clientRepository() != null) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sendSearchPacket() now returns immediately when menu.clientRepository() != null. In repository mode the search is handled entirely client-side, but the search string also flows into updateClientRepositoryView() -> repository.setView(...) only during containerTick/render. Since updateClientRepositoryView() is not invoked from the scheduleSearch/sendSearchPacket path (only the debounce timer calls sendSearchPacket, which early-returns), the new search text is not applied to the repository until the next frame. That is functionally fine for rendering, but the bigger issue is that updateClientRepositoryView() also resets scrollOffset = 0 in setView whenever the filter/sort/view changes — combined with the per-frame calls this means any transient preference difference resets user scroll position. Please confirm the search-filter change is intended to reset scroll on every tick while typing (as opposed to only when the filter actually stabilizes).

medium · bug

— Swear Review

Comment on lines +593 to +594
updateClientRepositoryView();
TerminalClientRepository repository = menu.clientRepository();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

updateClientRepositoryView() is invoked from three places: init(), renderBg() (every frame), and containerTick() (every tick). Although TerminalClientRepository.setView(...) deduplicates unchanged state internally and the recompute only runs when dirty, the per-frame call still needs to re-fetch and compare all view parameters and re-evaluate visibleRows-dependent scroll calculations on every frame. Since setView also resets scrollOffset on any change, any subtle drift in the passed preferences (e.g. a terminal preference object that is re-created per frame) will repeatedly reset the scroll position. Consider gating the per-frame call behind an explicit dirty flag (set by search responder, button changes, etc.) rather than calling it unconditionally in renderBg.

low · maintainability

— Swear Review

Comment on lines +68 to +71
if (buf.readerIndex() - packetStart > MAX_SERIALIZED_BYTES) {
throw new IllegalArgumentException(
"Terminal repository packet exceeds serialized size limit");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The serialized size check is only performed after every entry has been fully deserialized. Since this packet is registered via playToClient, decode() runs on the client and a malicious or buggy server can send up to MAX_ENTRIES_PER_PACKET entries with large NBT CompoundTags and ItemStacks; the client will allocate all of those objects before this limit is ever enforced, defeating the purpose of the safety check. Enforce the limit incrementally inside the entry loop (e.g., re-check buf.readerIndex() - packetStart > MAX_SERIALIZED_BYTES after each entry, and also guard each individual entry's NBT/ItemStack allocation) so oversized payloads are rejected before significant memory is allocated.

medium · bug

— Swear Review

@swear01
swear01 merged commit be28d70 into main Aug 22, 2026
2 checks passed
@swear01
swear01 deleted the perf/terminal-repository-sync branch August 22, 2026 03:20
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