From fadec887cd5f7c215096f8aecb34f05dbc8b3af7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 14:45:16 +0000 Subject: [PATCH 1/7] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.35?= =?UTF-8?q?=20[skip=20ci]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Emulator/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emulator/pom.xml b/Emulator/pom.xml index 3d2eec4c..1fd9adae 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -6,7 +6,7 @@ com.eu.habbo Habbo - 4.2.34 + 4.2.35 UTF-8 From 2c0ef9873c4b4ffc648f980de9c292b4ad7e5e38 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:44:19 +0000 Subject: [PATCH 2/7] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.36?= =?UTF-8?q?=20[skip=20ci]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Emulator/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emulator/pom.xml b/Emulator/pom.xml index 1fd9adae..405d52bb 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -6,7 +6,7 @@ com.eu.habbo Habbo - 4.2.35 + 4.2.36 UTF-8 From 0b142d184caae4b12df6ff05797717f72823c115 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 5 Jun 2026 19:21:31 +0000 Subject: [PATCH 3/7] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.37?= =?UTF-8?q?=20[skip=20ci]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Emulator/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emulator/pom.xml b/Emulator/pom.xml index 405d52bb..2edf2e0f 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -6,7 +6,7 @@ com.eu.habbo Habbo - 4.2.36 + 4.2.37 UTF-8 From bb4b9fb7f42701689d584e57b37f5137ebfad1c6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 06:56:00 +0000 Subject: [PATCH 4/7] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.38?= =?UTF-8?q?=20[skip=20ci]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Emulator/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emulator/pom.xml b/Emulator/pom.xml index 46a0a2c2..e53a579e 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -6,7 +6,7 @@ com.eu.habbo Habbo - 4.2.37 + 4.2.38 UTF-8 From ea88934e9ed9517068b614921d9aec1bdb8a6b0b Mon Sep 17 00:00:00 2001 From: John Doe Date: Sun, 7 Jun 2026 21:45:15 +0300 Subject: [PATCH 5/7] Safely handle JsonNull types --- .../java/com/eu/habbo/habbohotel/items/FurnidataReader.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java index 1af3dfc7..27cd61d1 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataReader.java @@ -109,13 +109,13 @@ public class FurnidataReader { JsonArray types = sectionObj.getAsJsonArray("furnitype"); for (JsonElement el : types) { JsonObject o = el.getAsJsonObject(); - if (!o.has("id") || !o.has("classname")) continue; + if (!o.has("id") || o.get("id").isJsonNull() || !o.has("classname") || o.get("classname").isJsonNull()) continue; out.add(new FurnidataEntry( o.get("id").getAsInt(), o.get("classname").getAsString(), type, - o.has("name") ? o.get("name").getAsString() : "", - o.has("description") ? o.get("description").getAsString() : "" + (o.has("name") && !o.get("name").isJsonNull()) ? o.get("name").getAsString() : "", + (o.has("description") && !o.get("description").isJsonNull()) ? o.get("description").getAsString() : "" )); } } From bfc6ff21a50d9c73f5a8775af443e32affb2ae0d Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sun, 7 Jun 2026 21:21:20 +0200 Subject: [PATCH 6/7] feat: resolve furnidata by configured source --- .../021_furnidata_config_cleanup.sql | 42 +++ .../items/FurnidataSourceResolver.java | 172 ++++++++++ .../items/FurnitureTextProvider.java | 21 +- .../furnieditor/FurniDataManager.java | 302 +++++++++++++++++- .../furnieditor/FurniEditorDetailEvent.java | 9 +- .../FurniEditorDetailComposer.java | 7 + .../furnieditor/FurniDataManagerTest.java | 81 +++++ 7 files changed, 603 insertions(+), 31 deletions(-) create mode 100644 Database Updates/Own_Database_RunFirst/021_furnidata_config_cleanup.sql create mode 100644 Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataSourceResolver.java create mode 100644 Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManagerTest.java diff --git a/Database Updates/Own_Database_RunFirst/021_furnidata_config_cleanup.sql b/Database Updates/Own_Database_RunFirst/021_furnidata_config_cleanup.sql new file mode 100644 index 00000000..814516e7 --- /dev/null +++ b/Database Updates/Own_Database_RunFirst/021_furnidata_config_cleanup.sql @@ -0,0 +1,42 @@ +-- 021_furnidata_config_cleanup.sql +-- Reverts the emulator_settings rows inserted by 021_furnidata_config.sql. +-- +-- Safe default: +-- This script ends with ROLLBACK. Run it once to preview the exact rows, then +-- change the final ROLLBACK to COMMIT only if the preview is correct. + +START TRANSACTION; + +DROP TEMPORARY TABLE IF EXISTS cleanup_furnidata_settings; +CREATE TEMPORARY TABLE cleanup_furnidata_settings ( + `key` VARCHAR(255) NOT NULL PRIMARY KEY +); + +INSERT INTO cleanup_furnidata_settings (`key`) VALUES + ('items.furnidata.names.enabled'), + ('items.furnidata.path'), + ('items.furnidata.max.bytes'), + ('items.furnidata.watch.enabled'), + ('items.furnidata.watch.debounce.ms'), + ('items.furnidata.watch.min.interval.ms'), + ('items.furnidata.delta.cap'), + ('furni.editor.import.url'), + ('furni.editor.import.cache.ms'); + +-- Preview rows that will be removed. +SELECT es.`key`, es.`value` +FROM emulator_settings es +JOIN cleanup_furnidata_settings cfs ON cfs.`key` = es.`key` +ORDER BY es.`key`; + +DELETE es +FROM emulator_settings es +JOIN cleanup_furnidata_settings cfs ON cfs.`key` = es.`key`; + +-- Preview remaining matching rows inside the transaction. +SELECT COUNT(*) AS remaining_furnidata_settings +FROM emulator_settings es +JOIN cleanup_furnidata_settings cfs ON cfs.`key` = es.`key`; + +-- Safe default. Change to COMMIT after reviewing the preview. +ROLLBACK; diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataSourceResolver.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataSourceResolver.java new file mode 100644 index 00000000..6d1f758f --- /dev/null +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnidataSourceResolver.java @@ -0,0 +1,172 @@ +package com.eu.habbo.habbohotel.items; + +import com.eu.habbo.Emulator; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.net.URI; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public final class FurnidataSourceResolver { + + private static final Logger LOGGER = LoggerFactory.getLogger(FurnidataSourceResolver.class); + + public enum Status { + RESOLVED, + SOURCE_MISSING, + CONFIG_MISSING, + UNRESOLVED_PLACEHOLDER, + ERROR + } + + public record Source(Path path, boolean directory, Status status, String message) { + public boolean ok() { + return this.status == Status.RESOLVED && this.path != null && Files.exists(this.path); + } + } + + private FurnidataSourceResolver() { + } + + public static Source resolve() { + try { + String override = Emulator.getConfig().getValue("items.furnidata.path", ""); + if (!override.isEmpty()) { + Path p = Paths.get(override); + if (Files.exists(p)) return new Source(p, Files.isDirectory(p), Status.RESOLVED, "items.furnidata.path"); + return new Source(p, Files.isDirectory(p), Status.SOURCE_MISSING, "items.furnidata.path does not exist"); + } + + String rendererConfigPath = Emulator.getConfig().getValue("furni.editor.renderer.config.path", ""); + String assetBasePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", ""); + + if (!rendererConfigPath.isEmpty()) { + Source fromRenderer = resolveFromRendererConfig(Paths.get(rendererConfigPath), assetBasePath.isEmpty() ? null : Paths.get(assetBasePath)); + if (fromRenderer.ok() || fromRenderer.status() == Status.UNRESOLVED_PLACEHOLDER) return fromRenderer; + } + + Source fallback = resolveFromAssetBase(assetBasePath); + if (fallback != null) return fallback; + + return new Source(null, false, Status.CONFIG_MISSING, "No furnidata source config found"); + } catch (Exception e) { + LOGGER.warn("FurnidataSourceResolver failed", e); + return new Source(null, false, Status.ERROR, e.getMessage() != null ? e.getMessage() : "Resolver error"); + } + } + + public static Source resolveFromRendererConfig(Path rendererConfig, Path assetBase) { + try { + if (rendererConfig == null || !Files.exists(rendererConfig)) { + return new Source(rendererConfig, false, Status.SOURCE_MISSING, "renderer-config path does not exist"); + } + + String raw = Files.readString(rendererConfig, StandardCharsets.UTF_8); + JsonObject rendererObj = JsonParser.parseString(FurnidataReader.stripJson5(raw)).getAsJsonObject(); + String furniUrl = expandRendererUrl(rendererObj, "furnidata.url"); + + if (furniUrl.isBlank()) return new Source(null, false, Status.CONFIG_MISSING, "furnidata.url is missing"); + if (hasUnresolvedPathPlaceholder(furniUrl)) return new Source(null, false, Status.UNRESOLVED_PLACEHOLDER, furniUrl); + + Source source = toLocalSource(assetBase, furniUrl); + if (source == null) return new Source(null, false, Status.CONFIG_MISSING, "furni.editor.asset.base.path is missing"); + if (!Files.exists(source.path())) return new Source(source.path(), source.directory(), Status.SOURCE_MISSING, "Resolved source does not exist"); + + return source; + } catch (Exception e) { + return new Source(null, false, Status.ERROR, e.getMessage() != null ? e.getMessage() : "renderer-config parse failed"); + } + } + + private static Source resolveFromAssetBase(String assetBasePath) { + if (assetBasePath == null || assetBasePath.isEmpty()) return null; + + Path dir = Paths.get(assetBasePath); + Path split = dir.resolve("furnidata"); + if (Files.isDirectory(split)) return new Source(split, true, Status.RESOLVED, "asset base split furnidata"); + + Path legacy = dir.resolve("FurnitureData.json"); + if (Files.exists(legacy)) return new Source(legacy, false, Status.RESOLVED, "asset base FurnitureData.json"); + + return new Source(dir, true, Status.SOURCE_MISSING, "No furnidata or FurnitureData.json under asset base"); + } + + public static String expandRendererUrl(JsonObject rendererObj, String key) { + if (rendererObj == null || !rendererObj.has(key)) return ""; + + String value = rendererObj.get(key).getAsString(); + for (int i = 0; i < 10; i++) { + int start = value.indexOf("${"); + if (start < 0) break; + + int end = value.indexOf('}', start + 2); + if (end < 0) break; + + String placeholder = value.substring(start + 2, end); + if (!rendererObj.has(placeholder)) break; + + value = value.substring(0, start) + rendererObj.get(placeholder).getAsString() + value.substring(end + 1); + } + + return value; + } + + public static Source toLocalSource(Path assetBase, String furniUrl) { + if (furniUrl == null || furniUrl.isBlank()) return null; + + String cleanUrl = stripQueryAndFragment(furniUrl); + boolean splitMode = cleanUrl.endsWith("/"); + + if (!cleanUrl.startsWith("http")) { + Path local = Paths.get(cleanUrl); + return new Source(local, splitMode || Files.isDirectory(local), Status.RESOLVED, "local furnidata.url"); + } + + if (assetBase == null) return null; + + String urlPath; + try { + urlPath = URI.create(cleanUrl).getPath(); + } catch (Exception e) { + int scheme = cleanUrl.indexOf("://"); + int pathStart = scheme >= 0 ? cleanUrl.indexOf('/', scheme + 3) : -1; + urlPath = pathStart >= 0 ? cleanUrl.substring(pathStart) : cleanUrl; + } + + String normalized = urlPath.replace('\\', '/'); + String baseName = assetBase.getFileName() != null ? assetBase.getFileName().toString() : ""; + String marker = "/" + baseName + "/"; + int markerIndex = baseName.isEmpty() ? -1 : normalized.indexOf(marker); + + Path candidate; + if (markerIndex >= 0) { + candidate = assetBase.resolve(normalized.substring(markerIndex + marker.length())); + } else if (splitMode) { + String trimmed = normalized.endsWith("/") ? normalized.substring(0, normalized.length() - 1) : normalized; + candidate = assetBase.resolve(trimmed.substring(trimmed.lastIndexOf('/') + 1)); + } else { + candidate = assetBase.resolve(normalized.substring(normalized.lastIndexOf('/') + 1)); + } + + return new Source(candidate, splitMode || Files.isDirectory(candidate), Status.RESOLVED, "renderer-config furnidata.url"); + } + + private static boolean hasUnresolvedPathPlaceholder(String value) { + if (value == null) return false; + return stripQueryAndFragment(value).contains("${"); + } + + private static String stripQueryAndFragment(String value) { + String out = value; + int q = out.indexOf('?'); + if (q >= 0) out = out.substring(0, q); + int h = out.indexOf('#'); + if (h >= 0) out = out.substring(0, h); + return out; + } +} diff --git a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java index ab2ae51f..6e9c6c92 100644 --- a/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java +++ b/Emulator/src/main/java/com/eu/habbo/habbohotel/items/FurnitureTextProvider.java @@ -5,7 +5,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.Paths; import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -44,7 +43,7 @@ public class FurnitureTextProvider { try { this.source = resolveSource(); if (this.source == null) { - LOGGER.warn("FurnitureTextProvider: no furnidata source resolved — names fall back to public_name"); + LOGGER.warn("FurnitureTextProvider: no furnidata source resolved - names fall back to public_name"); return; } reindex(new FurnidataReader(this.source, DEFAULT_MAX_BYTES).read()); @@ -90,20 +89,10 @@ public class FurnitureTextProvider { } private static Path resolveSource() { - String override = Emulator.getConfig().getValue("items.furnidata.path", ""); - if (!override.isEmpty()) { - Path p = Paths.get(override); - if (Files.exists(p)) return p; - LOGGER.warn("FurnitureTextProvider: items.furnidata.path '{}' does not exist", override); - return null; - } - String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", ""); - if (basePath.isEmpty()) return null; - Path dir = Paths.get(basePath); - Path split = dir.resolve("furnidata"); - if (Files.isDirectory(split)) return split; - Path legacy = dir.resolve("FurnitureData.json"); - return Files.exists(legacy) ? legacy : null; + FurnidataSourceResolver.Source source = FurnidataSourceResolver.resolve(); + if (source.ok()) return source.path(); + LOGGER.warn("FurnitureTextProvider: no furnidata source resolved ({}) - {}", source.status(), source.message()); + return null; } /** diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManager.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManager.java index eebdfc0f..e575a45f 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManager.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManager.java @@ -1,6 +1,7 @@ package com.eu.habbo.messages.incoming.furnieditor; import com.eu.habbo.Emulator; +import com.eu.habbo.habbohotel.items.FurnidataSourceResolver; import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; @@ -9,12 +10,15 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * Manages reading and writing of FurnitureData entries. @@ -43,24 +47,172 @@ public class FurniDataManager { private static final List DEFAULT_TIERS = Arrays.asList("core", "custom", "seasonal"); private static final List MANIFEST_NAMES = Arrays.asList("manifest.json5", "manifest.json"); private static final List SECTIONS = Arrays.asList("roomitemtypes", "wallitemtypes"); + private static volatile CachedIndex cachedIndex = null; + + public record LookupResult(String itemJson, String diagnosticJson) { + } /** * Get the JSON string for a specific item. * Returns "{}" if not found or on error. */ public static String getItemJson(int itemId) { - try { - ResolvedSource source = resolveSource(); - if (source == null) return "{}"; + return getItemJson(itemId, null); + } - if (source.directory) { - return findItemInSplitDir(source.path, itemId); + /** + * Get the JSON string for a specific item. + * Prefer the DB classname because items_base.id can diverge from the + * furnidata id after imports/reconciliations. Falls back to id lookup. + * Returns "{}" if not found or on error. + */ + public static String getItemJson(int itemId, String classname) { + return getItemLookup(itemId, classname).itemJson(); + } + + public static LookupResult getItemLookup(int itemId, String classname) { + FurnidataSourceResolver.Source source = FurnidataSourceResolver.resolve(); + if (source == null || !source.ok()) { + return new LookupResult("{}", diagnostic(source, itemId, classname, "source_missing")); + } + + try { + CachedIndex index = indexFor(source); + String key = baseClassname(classname); + String byClassname = key != null ? index.byClassname.get(key) : null; + if (byClassname != null) { + return new LookupResult(byClassname, diagnostic(source, itemId, classname, "matched_classname")); } - if (!Files.exists(source.path)) return "{}"; + String byId = index.byId.get(itemId); + if (byId != null) { + return new LookupResult(byId, diagnostic(source, itemId, classname, "matched_id")); + } - String content = readJson5(source.path); - return findItemInRoot(JsonParser.parseString(content).getAsJsonObject(), itemId); + String reason = index.empty ? "manifest_empty" : "not_found"; + return new LookupResult("{}", diagnostic(source, itemId, classname, reason)); + } catch (Exception e) { + LOGGER.warn("Failed to read FurnitureData for item " + itemId, e); + FurnidataSourceResolver.Source errorSource = new FurnidataSourceResolver.Source(source.path(), source.directory(), FurnidataSourceResolver.Status.ERROR, e.getMessage()); + return new LookupResult("{}", diagnostic(errorSource, itemId, classname, "error")); + } + } + + private static CachedIndex indexFor(FurnidataSourceResolver.Source source) { + long signature = sourceSignature(source.path()); + String sourceKey = source.path().toAbsolutePath().normalize().toString(); + CachedIndex current = cachedIndex; + if (current != null && current.sourceKey.equals(sourceKey) && current.signature == signature) return current; + + CachedIndex next = buildIndex(source, sourceKey, signature); + cachedIndex = next; + return next; + } + + private static CachedIndex buildIndex(FurnidataSourceResolver.Source source, String sourceKey, long signature) { + Map byId = new HashMap<>(); + Map byClassname = new HashMap<>(); + + if (source.directory()) { + indexSplitDir(source.path(), byId, byClassname); + } else { + try { + String content = readJson5(source.path()); + indexRoot(JsonParser.parseString(content).getAsJsonObject(), byId, byClassname); + } catch (Exception e) { + LOGGER.warn("Failed to parse furnidata source {}", source.path(), e); + } + } + + return new CachedIndex(sourceKey, signature, Map.copyOf(byId), Map.copyOf(byClassname), byId.isEmpty() && byClassname.isEmpty()); + } + + private static void indexSplitDir(Path baseDir, Map byId, Map byClassname) { + if (!Files.isDirectory(baseDir)) return; + + for (String tier : readTiersManifest(baseDir)) { + Path tierDir = baseDir.resolve(tier); + if (!Files.isDirectory(tierDir)) continue; + + for (String fileName : readFilesManifest(tierDir)) { + Path file = tierDir.resolve(fileName); + if (!Files.exists(file)) continue; + + try { + String content = readJson5(file); + indexRoot(JsonParser.parseString(content).getAsJsonObject(), byId, byClassname); + } catch (Exception e) { + LOGGER.warn("Failed to parse split gamedata file " + file, e); + } + } + } + } + + private static void indexRoot(JsonObject root, Map byId, Map byClassname) { + for (String section : SECTIONS) { + if (!root.has(section)) continue; + JsonObject sectionObj = root.getAsJsonObject(section); + if (!sectionObj.has("furnitype")) continue; + + for (JsonElement el : sectionObj.getAsJsonArray("furnitype")) { + JsonObject obj = el.getAsJsonObject(); + String json = obj.toString(); + + if (obj.has("id")) byId.put(obj.get("id").getAsInt(), json); + if (obj.has("classname")) { + String key = baseClassname(obj.get("classname").getAsString()); + if (key != null) byClassname.put(key, json); + } + } + } + } + + private static long sourceSignature(Path source) { + try { + if (source == null || !Files.exists(source)) return -1L; + if (!Files.isDirectory(source)) return Files.getLastModifiedTime(source).toMillis() ^ Files.size(source); + + final long[] signature = { 17L }; + try (var stream = Files.walk(source)) { + stream.filter(Files::isRegularFile).forEach(path -> { + try { + signature[0] = (signature[0] * 31L) ^ Files.getLastModifiedTime(path).toMillis() ^ Files.size(path); + } catch (Exception ignored) { + } + }); + } + return signature[0]; + } catch (Exception e) { + return System.nanoTime(); + } + } + + private static String diagnostic(FurnidataSourceResolver.Source source, int itemId, String classname, String reason) { + JsonObject obj = new JsonObject(); + obj.addProperty("reason", reason); + obj.addProperty("itemId", itemId); + obj.addProperty("classname", classname != null ? classname : ""); + obj.addProperty("sourcePath", source != null && source.path() != null ? source.path().toString() : ""); + obj.addProperty("sourceDirectory", source != null && source.directory()); + obj.addProperty("sourceStatus", source != null ? source.status().name() : "CONFIG_MISSING"); + obj.addProperty("message", source != null && source.message() != null ? source.message() : ""); + return obj.toString(); + } + + private record CachedIndex(String sourceKey, long signature, Map byId, Map byClassname, boolean empty) { + } + + static String findItemJson(Path source, boolean directory, int itemId, String classname) { + try { + if (directory) { + return findItemInSplitDir(source, itemId, classname); + } + + if (!Files.exists(source)) return "{}"; + + String content = readJson5(source); + String found = findItemInRoot(JsonParser.parseString(content).getAsJsonObject(), itemId, classname); + return found != null ? found : "{}"; } catch (Exception e) { LOGGER.warn("Failed to read FurnitureData for item " + itemId, e); } @@ -69,6 +221,13 @@ public class FurniDataManager { } private static String findItemInRoot(JsonObject root, int itemId) { + return findItemInRoot(root, itemId, null); + } + + private static String findItemInRoot(JsonObject root, int itemId, String classname) { + String byClassname = findItemInRootByClassname(root, classname); + if (byClassname != null) return byClassname; + for (String section : SECTIONS) { if (!root.has(section)) continue; JsonObject sectionObj = root.getAsJsonObject(section); @@ -85,11 +244,43 @@ public class FurniDataManager { return null; } + private static String findItemInRootByClassname(JsonObject root, String classname) { + String wanted = baseClassname(classname); + if (wanted == null) return null; + + for (String section : SECTIONS) { + if (!root.has(section)) continue; + JsonObject sectionObj = root.getAsJsonObject(section); + if (!sectionObj.has("furnitype")) continue; + JsonArray types = sectionObj.getAsJsonArray("furnitype"); + + for (JsonElement el : types) { + JsonObject obj = el.getAsJsonObject(); + if (!obj.has("classname")) continue; + + String actual = baseClassname(obj.get("classname").getAsString()); + if (wanted.equals(actual)) return obj.toString(); + } + } + + return null; + } + + private static String baseClassname(String classname) { + if (classname == null) return null; + + int star = classname.indexOf('*'); + String base = star >= 0 ? classname.substring(0, star) : classname; + base = base.trim().toLowerCase(java.util.Locale.ROOT); + + return base.isEmpty() ? null : base; + } + /** * Walk the split directory layout looking for an item by id. * Later tiers (custom, then seasonal) override earlier ones. */ - private static String findItemInSplitDir(Path baseDir, int itemId) { + private static String findItemInSplitDir(Path baseDir, int itemId, String classname) { if (!Files.isDirectory(baseDir)) return "{}"; List tiers = readTiersManifest(baseDir); @@ -107,7 +298,7 @@ public class FurniDataManager { try { String content = readJson5(file); JsonObject obj = JsonParser.parseString(content).getAsJsonObject(); - String match = findItemInRoot(obj, itemId); + String match = findItemInRoot(obj, itemId, classname); if (match != null) found = match; } catch (Exception e) { LOGGER.warn("Failed to parse split gamedata file " + file, e); @@ -239,7 +430,7 @@ public class FurniDataManager { * Represents the resolved location of the furnidata source: either a single * file or a directory in split-layout mode. */ - private static class ResolvedSource { + static class ResolvedSource { final Path path; final boolean directory; @@ -270,9 +461,9 @@ public class FurniDataManager { if (!rendererObj.has("furnidata.url")) return null; - String furniUrl = rendererObj.get("furnidata.url").getAsString(); + String furniUrl = expandRendererUrl(rendererObj, "furnidata.url"); - if (furniUrl.contains("${")) { + if (hasUnresolvedPathPlaceholder(furniUrl)) { Path fallback = fallbackToBasePath(); return fallback != null ? new ResolvedSource(fallback, Files.isDirectory(fallback)) : null; } @@ -296,6 +487,9 @@ public class FurniDataManager { String basePath = Emulator.getConfig().getValue("furni.editor.asset.base.path", ""); if (basePath.isEmpty()) return null; + ResolvedSource mapped = toLocalSource(Paths.get(basePath), furniUrl); + if (mapped != null) return mapped; + if (splitMode) { // Derive the directory name from the URL: take the last non-empty // segment before the trailing slash. e.g. https://x/y/furnidata/ -> "furnidata" @@ -326,4 +520,86 @@ public class FurniDataManager { if (Files.exists(legacy)) return legacy; return null; } + + static String expandRendererUrl(JsonObject rendererObj, String key) { + if (rendererObj == null || !rendererObj.has(key)) return ""; + + String value = rendererObj.get(key).getAsString(); + for (int i = 0; i < 10; i++) { + int start = value.indexOf("${"); + if (start < 0) break; + + int end = value.indexOf('}', start + 2); + if (end < 0) break; + + String placeholder = value.substring(start + 2, end); + if (!rendererObj.has(placeholder)) break; + + String replacement = rendererObj.get(placeholder).getAsString(); + value = value.substring(0, start) + replacement + value.substring(end + 1); + } + + return value; + } + + private static boolean hasUnresolvedPathPlaceholder(String value) { + if (value == null) return false; + + String pathOnly = stripQueryAndFragment(value); + return pathOnly.contains("${"); + } + + static ResolvedSource toLocalSource(Path assetBase, String furniUrl) { + if (furniUrl == null || furniUrl.isBlank()) return null; + + String cleanUrl = stripQueryAndFragment(furniUrl); + boolean splitMode = cleanUrl.endsWith("/"); + + if (!cleanUrl.startsWith("http")) { + Path local = Paths.get(cleanUrl); + return new ResolvedSource(local, splitMode || Files.isDirectory(local)); + } + + if (assetBase == null) return null; + + String urlPath; + try { + urlPath = URI.create(cleanUrl).getPath(); + } catch (Exception e) { + int scheme = cleanUrl.indexOf("://"); + int pathStart = scheme >= 0 ? cleanUrl.indexOf('/', scheme + 3) : -1; + urlPath = pathStart >= 0 ? cleanUrl.substring(pathStart) : cleanUrl; + } + + String normalizedUrlPath = urlPath.replace('\\', '/'); + String baseName = assetBase.getFileName() != null ? assetBase.getFileName().toString() : ""; + String marker = "/" + baseName + "/"; + + Path candidate; + int markerIndex = baseName.isEmpty() ? -1 : normalizedUrlPath.indexOf(marker); + if (markerIndex >= 0) { + String relative = normalizedUrlPath.substring(markerIndex + marker.length()); + candidate = assetBase.resolve(relative); + } else if (splitMode) { + String trimmed = normalizedUrlPath.endsWith("/") + ? normalizedUrlPath.substring(0, normalizedUrlPath.length() - 1) + : normalizedUrlPath; + String dirName = trimmed.substring(trimmed.lastIndexOf('/') + 1); + candidate = assetBase.resolve(dirName); + } else { + String filename = normalizedUrlPath.substring(normalizedUrlPath.lastIndexOf('/') + 1); + candidate = assetBase.resolve(filename); + } + + return new ResolvedSource(candidate, splitMode || Files.isDirectory(candidate)); + } + + private static String stripQueryAndFragment(String value) { + String out = value; + int q = out.indexOf('?'); + if (q >= 0) out = out.substring(0, q); + int h = out.indexOf('#'); + if (h >= 0) out = out.substring(0, h); + return out; + } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDetailEvent.java b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDetailEvent.java index b904fb55..c4917fd1 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDetailEvent.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/incoming/furnieditor/FurniEditorDetailEvent.java @@ -41,6 +41,7 @@ public class FurniEditorDetailEvent extends MessageHandler { int usageCount = 0; List> catalogItems = new ArrayList<>(); String furniDataJson = "{}"; + String furniDataDiagnosticJson = "{}"; try (Connection connection = Emulator.getDatabase().getDataSource().getConnection()) { // Load full item data @@ -86,11 +87,15 @@ public class FurniEditorDetailEvent extends MessageHandler { // Try to read furnidata.json entry try { - furniDataJson = FurniDataManager.getItemJson(itemId); + Object classname = item.get("item_name"); + FurniDataManager.LookupResult lookup = FurniDataManager.getItemLookup(itemId, classname != null ? classname.toString() : null); + furniDataJson = lookup.itemJson(); + furniDataDiagnosticJson = lookup.diagnosticJson(); } catch (Exception e) { furniDataJson = "{}"; + furniDataDiagnosticJson = "{}"; } - client.sendResponse(new FurniEditorDetailComposer(item, usageCount, catalogItems, furniDataJson)); + client.sendResponse(new FurniEditorDetailComposer(item, usageCount, catalogItems, furniDataJson, furniDataDiagnosticJson)); } } diff --git a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorDetailComposer.java b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorDetailComposer.java index 7c351af8..0d627cee 100644 --- a/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorDetailComposer.java +++ b/Emulator/src/main/java/com/eu/habbo/messages/outgoing/furnieditor/FurniEditorDetailComposer.java @@ -12,12 +12,18 @@ public class FurniEditorDetailComposer extends MessageComposer { private final int usageCount; private final List> catalogItems; private final String furniDataJson; + private final String furniDataDiagnosticJson; public FurniEditorDetailComposer(Map item, int usageCount, List> catalogItems, String furniDataJson) { + this(item, usageCount, catalogItems, furniDataJson, "{}"); + } + + public FurniEditorDetailComposer(Map item, int usageCount, List> catalogItems, String furniDataJson, String furniDataDiagnosticJson) { this.item = item; this.usageCount = usageCount; this.catalogItems = catalogItems; this.furniDataJson = furniDataJson; + this.furniDataDiagnosticJson = furniDataDiagnosticJson; } @Override @@ -71,6 +77,7 @@ public class FurniEditorDetailComposer extends MessageComposer { // furnidata JSON string this.response.appendString(this.furniDataJson != null ? this.furniDataJson : "{}"); + this.response.appendString(this.furniDataDiagnosticJson != null ? this.furniDataDiagnosticJson : "{}"); return this.response; } diff --git a/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManagerTest.java b/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManagerTest.java new file mode 100644 index 00000000..fa3db546 --- /dev/null +++ b/Emulator/src/test/java/com/eu/habbo/messages/incoming/furnieditor/FurniDataManagerTest.java @@ -0,0 +1,81 @@ +package com.eu.habbo.messages.incoming.furnieditor; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import com.eu.habbo.habbohotel.items.FurnidataSourceResolver; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +class FurniDataManagerTest { + + @Test + void findsItemByClassnameBeforeDbId(@TempDir Path dir) throws Exception { + Path file = dir.resolve("FurnitureData.json"); + Files.writeString(file, """ + { + "roomitemtypes": { "furnitype": [ + { "id": 9999, "classname": "throne", "name": "Throne", "description": "Royal seat" } + ]}, + "wallitemtypes": { "furnitype": [] } + } + """); + + String json = FurniDataManager.findItemJson(file, false, 230, "throne"); + + assertNotEquals("{}", json); + assertTrue(json.contains("\"classname\":\"throne\"")); + assertTrue(json.contains("\"id\":9999")); + } + + @Test + void fallsBackToItemIdWhenClassnameIsMissing(@TempDir Path dir) throws Exception { + Path file = dir.resolve("FurnitureData.json"); + Files.writeString(file, """ + { + "roomitemtypes": { "furnitype": [ + { "id": 230, "classname": "db_only_match", "name": "DB ID Match", "description": "" } + ]}, + "wallitemtypes": { "furnitype": [] } + } + """); + + String json = FurniDataManager.findItemJson(file, false, 230, "missing_classname"); + + assertNotEquals("{}", json); + assertTrue(json.contains("\"classname\":\"db_only_match\"")); + } + + @Test + void expandsRendererConfigPlaceholders() { + JsonObject config = JsonParser.parseString(""" + { + "gamedata.url": "http://localhost:5173/nitro-assets/gamedata", + "furnidata.url": "${gamedata.url}/FurnitureData.json?t=${timestamp}" + } + """).getAsJsonObject(); + + String url = FurnidataSourceResolver.expandRendererUrl(config, "furnidata.url"); + + assertEquals("http://localhost:5173/nitro-assets/gamedata/FurnitureData.json?t=${timestamp}", url); + } + + @Test + void mapsRendererUrlRelativeToAssetBase(@TempDir Path dir) { + Path assetBase = dir.resolve("nitro-assets"); + + FurnidataSourceResolver.Source source = FurnidataSourceResolver.toLocalSource( + assetBase, + "http://localhost:5173/nitro-assets/gamedata/FurnitureData.json?t=123" + ); + + assertNotNull(source); + assertEquals(assetBase.resolve("gamedata").resolve("FurnitureData.json"), source.path()); + assertFalse(source.directory()); + } +} From d383c43bbffa254f2890dba536801ff0b731614c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 7 Jun 2026 21:19:40 +0000 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=86=99=20Bump=20version=20to=204.2.39?= =?UTF-8?q?=20[skip=20ci]?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Emulator/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emulator/pom.xml b/Emulator/pom.xml index e53a579e..022cec42 100644 --- a/Emulator/pom.xml +++ b/Emulator/pom.xml @@ -6,7 +6,7 @@ com.eu.habbo Habbo - 4.2.38 + 4.2.39 UTF-8