Design for sourcing furni display names from furnidata JSON (DB keeps technical data), with a live delta-broadcast pipeline (emulator file-watch -> renderer patch -> client refresh) and a security hardening section. Cross-repo reference copy.
16 KiB
Furni names from JSON (server-authoritative) — Design
- Date: 2026-06-04
- Status: Draft for review
- Scope: Cross-repo — Arcturus (emulator), Nitro_Render_V3 (renderer), Nitro-V3 (client)
- Out of scope: furni-editor feature/packets, NitroV3-Housekeeping (CMS), server-side multi-language, description rendering in the infostand.
1. Problem & motivation
Today a furni's display name lives in two independent places that drift apart:
- DB —
items_base.public_name(Item.fullName), used by the emulator. - furnidata JSON — used by the client (the client already resolves all visible furni names/descriptions from furnidata, keyed by classname).
This forces admins to maintain names twice and causes mismatches. We want one source of truth: the furnidata JSON owns display names & descriptions, the DB owns technical data. Editing furnidata should reflect everywhere — server-pronounced strings and every connected client — live, with no DB edit and no restart.
This is a single, unified refactor whose payoff is admin furni management: one place to edit, consistent everywhere.
2. Source-of-truth contract
| Concern | Owner | Storage | Read by |
|---|---|---|---|
classname (item_name / Item.name) |
DB | items_base.item_name |
join key → furnidata and .nitro asset; isPet/isBot; wired wf_ fallback |
technical data (dimensions, stateCount, flags, interaction, effects) |
DB | items_base.* |
emulator simulation |
| display name | JSON | furnidata (per classname) | emulator (getDisplayName) + client (furnidata, unchanged) |
| description | JSON | furnidata (per classname) | client only (catalog) — no server consumer |
Invariants:
- The bridge is
classname, not a numeric id.Item.name↔ furnidataclassname. public_name(Item.fullName) is NOT removed: it remains (a) the fallback when a classname is missing from furnidata, and (b) the technical token for wired furni (Item.java:107-116readsfullName.startsWith("wf_")). No schema migration. No DROP.- There is no
descriptioncolumn initems_base; description is JSON-only and has no server consumer → the emulator gets nogetDescription(). - One furnidata artifact is shared truth: the file the emulator indexes must be the same furnidata the client loads (deploy invariant, §7).
- Server emits names in the base locale of the furnidata file. Player-facing multi-language stays a client localization-layer concern (unchanged).
3. Architecture — two independent pieces
The refactor is two pieces that share only the furnidata file and one new packet. They do not depend on each other.
- Piece 1 — Server-authoritative names. The emulator's pronounced names come from furnidata.
- Piece 2 — Liveness via delta. When the furnidata file changes, connected clients (and the server index) update without reconnecting, via a minimal delta broadcast.
4. Piece 1 — Emulator (server-authoritative names)
4.1 FurnidataReader (new, package com.eu.habbo.habbohotel.items)
A neutral, shared reader extracted so the editor is not touched. Responsibilities:
- Resolve the furnidata source reusing the same already-configured path as the editor:
furni.editor.renderer.config.path→furnidata.url→furni.editor.asset.base.path(seeFurniDataManager.resolveSource()for the exact resolution we mirror). Default to those values so admins configure once. - Support both layouts the editor already supports: single file (
FurnitureData.json) and split-tier directory (core/custom/seasonal,manifest.json5, JSON5 with comments; later tiers override earlier). Reuse the JSON5 strip logic (extract to the shared reader). - Parse
roomitemtypes(floor) andwallitemtypes(wall) → return a flat list ofFurnidataEntry { int id, String classname, FurnitureType type, String name, String description }.
Security requirements on the reader (furnidata is untrusted input):
- Path-traversal guard. When resolving split-tier manifest entries
(
tiers[],files[]) viadir.resolve(name), normalize the result and reject any path that escapes the configured base dir (absolute paths,..). The existingFurniDataManagerlacks this guard — the shared reader MUST add it (do not propagate the gap). - Size cap. Refuse to load a furnidata file/dir above a configurable max (default e.g. 64 MB) to bound parse cost.
- Sanitization at the boundary. Every
name/descriptionis sanitized on load: truncate to 256 chars, strip control characters and newlines, and neutralize%tokens (so they cannot inject intoString.replaceplaceholder chains, server- or wired-side). Normal text/emoji/non-latin scripts pass through. - Fail-safe. Any IO/parse error is caught and logged; the provider keeps the last-good index (or empty on first load) and never throws — boot must not crash on a bad furnidata.
4.2 FurnitureTextProvider (new, package items)
- Holds
volatile Map<String /*classname lowercase*/, FurniText {int id, String name, String description}>. reindex(): read viaFurnidataReader→ build a new immutable map → compute delta vs the previous map (§5) → atomically swap the reference → return the delta.- Initialized in
GameEnvironment.loadnearItemManager. Resolution is lazy, so boot order is not critical andItemobjects do not depend on the provider at load time. - Toggle
items.furnidata.names.enabled(defaulttrue). Whenfalse,getDisplayName()returns the DB value (instant rollback, no recompile).
4.3 Item.getDisplayName()
String getDisplayName():
if !enabled: return fullName
FurniText t = FurnitureTextProvider.get(this.name /* classname, lowercased */)
return (t != null && t.name not blank) ? t.name : this.fullName // never null
No getDescription() on the server (no consumer).
4.4 Swap list (exhaustive — verified)
Replace item.getFullName() → item.getDisplayName() at exactly these 6 sites:
| Site | Context |
|---|---|
CatalogBuyItemAsGiftEvent.java:251 |
LTD daily-total alert (gift) |
CatalogBuyItemAsGiftEvent.java:262 |
LTD daily-item alert (gift) |
CatalogManager.java:1057 |
LTD daily-total alert (buy) |
CatalogManager.java:1063 |
LTD daily-item alert (buy) |
WiredTextPlaceholderUtil.java:282 |
wired %furni.name% (keep existing getName() ultimate fallback) |
WatchAndEarnRewardComposer.java:21 |
appendString(...) — sends name in a packet |
Do NOT change (technical, use item_name/classname): PresentItemOpenedComposer:24,
GiftCommand:72, SendGift:82, SellItemEvent:37,45, CloseDiceEvent:34, isPet/isBot, and the
wired wf_ fallback in Item.load. The catalog offer/page serialization sends no display
name (CatalogItem serializes catalog_name + sprite only) — confirmed, nothing to change there.
5. Piece 2 — Liveness via delta
5.1 Server: file watcher + diff + broadcast
- A
WatchServicewatches the resolved furnidata location on a single, serialized watcher thread (so reindex never races itself). For the split-tier layout, register the base dir and each tier dir. Debounce (~750 ms) to coalesce burst writes, plus a minimum interval between broadcasts (e.g. ≥5 s) to cap amplification. - On settle →
FurnitureTextProvider.reindex()→ diff old vs new by classname:- added (new classname) and changed (name or description differs) → included.
- removed classnames → ignored (rare; resolved on client reconnect).
- Broadcast decision (anti-DoS):
- delta empty → no broadcast.
- delta size ≤ cap (e.g. 500 entries) → broadcast
FurnitureDataReloadin delta mode. - delta size > cap (mass replace) → broadcast in reload-hint mode (compact signal; clients re-load furnidata at next opportunity) instead of a giant per-client payload.
- The broadcast is triggered only by the file watcher — there is no client-initiated reload path. This is a security property to preserve (clients cannot induce reindex/broadcast).
5.2 Wire contract — new packet FurnitureDataReload
- Composer (Arcturus):
FurnitureDataReloadComposer, new dedicated header id (pick a free id; document on both sides). Two modes:int mode // 0 = delta, 1 = reload-hint // mode == 0 (delta): int count // bounded by the server cap; the client MUST also bound it on read count × { string type // "S" (floor) | "I" (wall) int id // furnidata numeric id (for localization-key + FurnitureData lookup) string classname string name // already sanitized server-side string description } // mode == 1 (reload-hint): no further fields (optionally an int revision for cache-busting) - Parser/Event (renderer):
FurnitureDataReloadEvent+FurnitureDataReloadParserreading the same shape. The parser boundscount(reject/clamp absurd values) and tolerates truncation (bytesAvailablepattern) so a malformed/MITM payload cannot allocate unbounded memory. Registered inSessionDataManager.init()viaGetCommunication().registerMessageEvent(new FurnitureDataReloadEvent(...))(same pattern as the existingFurniDataUpdatedEventregistration, but a distinct handler).
5.3 Renderer: separate patch path (no editor reuse)
- New method, e.g.
SessionDataManager.applyFurnidataDelta(entries)— distinct from the editor'sapplyLiveFurnitureNameUpdate(...)(SessionDataManager.ts:84), which we leave intact. - Delta mode (0): for each entry, patch the corresponding
FurnitureData(floor/wall, byid) — update_localizedNameand_description— and re-register the localization keysroomItem.name/desc.{id}/wallItem.name/desc.{id}(mirrorsFurnitureDataLoader:105-110). - Reload-hint mode (1): re-run the furnidata load (
FurnitureDataLoader, re-fetchingfurnidata.urlwith cache-bust) — the appropriate response to a mass change. - In both modes, after the batch dispatch the window event once:
window.dispatchEvent(new CustomEvent('nitro-localization-updated')).
5.4 Client: zero changes
All three furni surfaces already subscribe to nitro-localization-updated and re-derive:
- catalog —
useCatalog.ts:919 - inventory —
useInventoryFurni.ts:137(→refreshGroupItemsLocalization) - infostand —
useAvatarInfoWidget.ts:425(→getFurniInfo, which readsfurnitureData.name)
No Nitro-V3 edits are required for Piece 2.
6. Admin-facing outcome
Edit one place — the furnidata JSON — and display names update live across:
server-pronounced strings (catalog LTD alerts, wired %furni.name%, Watch&Earn), and every
connected client's catalog, inventory, and furni infostand. No DB edit, no restart, no double
maintenance.
7. Constraints, risks, invariants
- Locale no-clobber. If per-locale furni text override files are in use (they override
roomItem.name.{id}after furnidata load), a live delta that re-registers base names would revert overridden ids to base. Mitigation options for the plan: re-apply active overrides after the delta, or skip the localization-key patch for ids with an active override (still patch theFurnitureDataobject). For single-furnidata setups (typical retro) there is no override and no issue. Document the limitation. - Deploy invariant.
furni.editor.asset.base.path/furnidata.url(what the emulator watches) and the furnidata the client loaded must be the same artifact, else the server delta references entries the client doesn't have. public_namefallback. Wiredwf_items absent from furnidata would show the rawwf_…token as their display name (internal/invisible furni — acceptable).- Split-layout watcher. The watcher must register all tier dirs; missing a tier dir means live updates from that tier are not detected (resolved on reconnect).
- Performance.
getDisplayName()is a singleHashMaplookup on cold paths (catalog alerts, wired text, Watch&Earn) — negligible.
8. Security
With this refactor the furnidata becomes a security-relevant input: its strings now flow into
server output (catalog LTD alerts, wired %furni.name%, the Watch&Earn packet) and into a
broadcast to every connected client. Regular players cannot influence names (names are admin-owned,
keyed by classname); the threat is untrusted furnidata content (third-party furni packs,
imports, a compromised editor/supply chain). Controls:
- Boundary sanitization (see §4.1): cap 256 chars, strip control/newline, neutralize
%. Neutralizing%at load makes everyString.replace("%itemname%", name)/%furni.name%site injection-safe; as defense-in-depth, substitute the (untrusted) furni name last in any placeholder chain. - Path-traversal guard in the shared reader (§4.1) — reject manifest paths escaping the base dir. Closes a gap the current editor reader does not cover.
- DoS / amplification controls (§5.1): single serialized watcher thread, debounce + minimum broadcast interval, delta-size cap with reload-hint fallback for mass changes, furnidata file-size cap.
- Fail-safe loading (§4.1): bad/corrupt furnidata never crashes boot; last-good index is kept;
getDisplayName()falls back topublic_name. - Robust client parser (§5.2): bound
count, tolerate truncation — a malformed/MITMFurnitureDataReloadcannot allocate unbounded memory client-side. - No client-triggered reload (§5.1): only the file watcher broadcasts. Do not add any client→server reload request. Preserve this property.
- Minimal disclosure: the delta carries only
name/description(already public via furnidata) — never other fields from the server-side file. - Concurrency:
volatileindex reference + atomic swap + single reindex thread → no torn reads.
9. Testing
- Emulator (JUnit):
FurnidataReaderparses single-file and split-tier (JSON5, tier override);FurnitureTextProviderlookup by lowercased classname, fallback topublic_namewhen absent, atomic reindex;reindex()diff produces correct added/changed delta and ignores removals;Item.getDisplayName()honors the enable toggle. - Renderer (Vitest):
FurnitureDataReloadParserreads the payload shape;applyFurnidataDeltapatches floor/wallFurnitureDataby id, re-registers localization keys, dispatchesnitro-localization-updatedonce. - Client (Vitest): existing subscribers (
useCatalog,useInventoryFurni,useAvatarInfoWidget) refresh onnitro-localization-updated(regression guard; no new code). - Manual acceptance: edit a furni name in furnidata → live update in catalog + inventory +
infostand without refresh; a wired
%furni.name%sign and a Watch&Earn reward show the new name. - Security tests: reader rejects a split-tier manifest with
../traversal; a name containing%limit%/%user.name%does not inject into catalog alerts or wired text (%neutralized); oversized furnidata is refused; corrupt furnidata keeps last-good index and does not crash; a mass change emits a reload-hint (not a giant delta); the client parser clamps an absurdcount.
10. Open questions
- Free header id for
FurnitureDataReload(assign during implementation; document both sides). - Whether any retro on this stack actually ships per-locale furni override files (governs whether constraint §7.1 is live or moot).