Drop the separate UserPermissionsMapEvent / UserPermissionsMapParser
and the IncomingHeader.USER_PERMISSIONS_MAP = 10070 registration —
the resolved permission map now rides on the existing
UserPermissionsEvent as a third optional trailing block, after the
rank metadata one. Same wire data, one fewer packet, one fewer
event registration, one fewer handler.
Wire layout (UserPermissionsEvent / header 411):
int clubLevel
int securityLevel
bool isAmbassador
--- rank metadata (Arcturus ≥ 4.2.10) ---
int rankId
string rankName
string rankBadge
string rankPrefix
string rankPrefixColor
--- resolved permission map (Arcturus ≥ 4.2.10) ---
int count
loop: string permission_key + int value (1=ALLOWED, 2=ROOM_OWNER)
Both trailing blocks are guarded by `bytesAvailable` in
UserPermissionsParser so older emulators that don't append them
still parse cleanly.
SessionDataManager.onUserPermissionsEvent is now the single handler:
- updates clubLevel/securityLevel/isAmbassador/rank* AND _permissions;
- invalidates BOTH the user-data snapshot and the permissions
snapshot (dispatching the two distinct
NitroEventType.SESSION_DATA_UPDATED / USER_PERMISSIONS_UPDATED
events).
The two distinct invalidation events stay so React consumers can
subscribe granularly — useHasPermission(key) only triggers on a real
permission map flip, not on every session-data bump.
Companion Arcturus change (feat/react19-emu-update) folds
UserPermissionsMapComposer into UserPermissionsComposer and removes
the second sendResponse in HabboManager.setRank +
SecureLoginEvent.
Verification: yarn compile:fast clean, vitest 138/138.
Adds the wire pipeline for `Outgoing.UserPermissionsMapComposer = 10070`
shipped by Arcturus-Morningstar-Extended ≥ 4.2.10. The composer sends
the resolved `permission_definitions` map for the current user
(filtered to ALLOWED / ROOM_OWNER entries) at login and after every
`HabboManager.setRank` — so a runtime promote/demote re-derives every
React-side permission gate.
- NitroEventType.USER_PERMISSIONS_UPDATED — new invalidation event.
- IncomingHeader.USER_PERMISSIONS_MAP = 10070.
- UserPermissionsMapParser reads `int count + (string key, int value)*`.
- UserPermissionsMapEvent + NitroMessages registration.
- SessionDataManager._permissions Map + getPermissionsSnapshot()
referentially-stable per the snapshot convention. New handler
onUserPermissionsMapEvent copies the parser map into the manager
(so the parser's mutable reference doesn't leak) and invalidates.
- ISessionDataManager.getPermissionsSnapshot() — public contract.
React-side consumers ship in the companion commit on
feat/react19-modernization. The wire is backward-compatible: older
emulators never send the packet, the snapshot stays empty Map, and
all useHasPermission(key) gates return false (mod-only UI hidden by
default = safe).
Verification: tsgo clean, vitest 138/138.
Extend the `UserPermissionsEvent` parser and `IUserDataSnapshot` with
rank metadata mirrored from the Arcturus `permission_ranks` table:
rankId, rankName, rankBadge, rankPrefix, rankPrefixColor.
The new fields are appended to the wire payload AFTER the existing
[clubLevel, securityLevel, isAmbassador] triple. The parser guards
the trailing block with `if(!wrapper.bytesAvailable) return true;`
so older emulators (that don't write the extension) keep working —
the snapshot just exposes the defaults (rankId=0, empty strings) in
that case.
`SessionDataManager.onUserPermissionsEvent` stores the values; the
snapshot builder includes them; existing
`invalidateUserDataSnapshot()` semantics flow through unchanged, so
a runtime promote/demote (via `HabboManager.setRank` →
`UserPermissionsComposer`) auto-flips the React-side
`useUserRank()` / `useHasRankLevel()` / `useIsRank()` consumers in
the Nitro-V3 client.
Companion changes:
- Arcturus-Morningstar-Extended:
`UserPermissionsComposer.composeInternal()` now appends the 5
extra fields (pending operator commit; see
../Arcturus-Morningstar-Extended/Emulator/src/main/java/
com/eu/habbo/messages/outgoing/users/UserPermissionsComposer.java).
- Nitro-V3:
`useSessionSnapshots.ts` exposes the new family
(useUserRank / useHasRankLevel / useIsRank), replacing the
SecurityLevel-based wrappers (useIsModerator etc.) that hardcoded
the renderer enum names — those don't match the operator's
`permission_ranks.rank_name` column.
Verification: tsgo clean, vitest 138/138.
Three improvements on top of duckietm/Dev's new JSON5 + split-aware
gamedata loader:
1. Parallel fetches inside loadGamedata: every file declared in a
tier's manifest is now fetched with Promise.all. The merge step
still walks the parts in declared order so override semantics
(core -> custom -> seasonal, and within-tier declaration order)
are preserved. Root-manifest files and per-tier manifest discovery
also run concurrently.
2. tryFetchManifest distinguishes 404 from other failures. The
previous tryFetchOrNull silently treated parse errors and 5xx as
"manifest missing", so a malformed manifest.json5 made an entire
tier vanish from the boot. Now only HTTP 404 returns null; every
other failure propagates.
3. New ConfigJsonError class with phase ('fetch' | 'parse'),
sourceUrl, and optional httpStatus. Exported isMissingResource()
helper lets callers check for 404 without string-matching.
Also:
- mergeGamedata warns via NitroLogger when an array looks keyed by
id/classname/name on >=80% of items but a few are missing the
key (the previous behavior fell back to concat() and produced
silent duplicates).
- Removed the dead text === null/undefined branch in parseConfigJson
(Response.text() never returns null).
Verified: tsgo clean, 138/138 tests pass on the renderer, 207/207
tests pass on the client (no behavioral change to existing callers).
Introduces loadGamedata(url, options?) and mergeGamedata(a, b) in
@nitrots/utils. The loader transparently accepts:
- a single-file URL (legacy) -> parsed as before
- a directory URL ending with '/' -> tier-merged from core/custom/seasonal,
each tier driven by its own manifest.json5
Merge rules:
- arrays of objects sharing an id key (id, classname, name): merged by id,
later layers overriding earlier ones
- arrays without an id key: concatenated
- plain objects: recursive merge per key
- anything else: later value wins
All gamedata consumers (FurnitureDataLoader, ProductDataLoader,
EffectAssetDownloadManager, AvatarRenderManager actions+figuredata,
LocalizationManager) are migrated to loadGamedata. Behaviour is unchanged
for single-file URLs, so existing deployments need no config changes;
opt-in to split mode by appending '/' to the URL once the layout is in
place.
README updated with the directory layout, merge table and programmatic
usage example. The companion CLI splitter that produces the core/ tier
from legacy files lives in the Nitro V3 client repo.
Add the four 2026-05-18 snapshot additions (IgnoredUsers,
GroupInformation, UserDataManager room list, SoundManager volumes) to
the snapshot-getter table with their invalidation events, plus the
3-step checklist for adding new ones. Also document the flat
bytesAvailable early-return pattern as the canonical shape for
optional-trailing-field parsers (replaces the brittle nested
if-chain). Note the SoundManager volume-diff bug fix landed alongside.
Two parsers handle "one tier of optional trailing fields per emulator
release" via nested if(wrapper.bytesAvailable) chains. The semantics
were correct but each new block sat one extra indent deeper than the
previous one, and adding tier N+1 quietly meant re-indenting everything
above it. Replaced with a flat early-return chain that's diff-friendly
when the next emulator version ships a new trailing block:
if(!wrapper.bytesAvailable) return true;
// block N reads
if(!wrapper.bytesAvailable) return true;
// block N+1 reads
…
Functionally equivalent — defaults still come from flush(), older
servers still bail at whichever tier they don't ship. Each block is
now also documented inline so the order/contract is obvious without
cross-referencing Arcturus.
In UserProfileParser, also straightened the cardBackgroundId tier:
was an inline `(wrapper.bytesAvailable ? readInt() : 0)` mid-block;
now a proper `if(!bytesAvailable) return true;` guard between blocks,
matching the rest of the chain.
Extends the snapshot pattern to the three audio volume levels (system /
furni / trax) so volume-slider widgets on the React client can subscribe
to a single source of truth via useSyncExternalStore.
API additions on ISoundManager:
- systemVolume / furniVolume getters (parity with the existing
traxVolume getter)
- getVolumesSnapshot(): Readonly<ISoundVolumesSnapshot> with the same
lazy-frozen + invalidation-on-change semantics as the user/session
snapshots
- new ISoundVolumesSnapshot { system, furni, trax } interface
New event: NitroEventType.SOUND_VOLUMES_UPDATED. Dispatched only when
the incoming NitroSettingsEvent.SETTINGS_UPDATED actually changes one
of the three volumes (a no-op refresh stays quiet).
While in there, fixed a real bug: the previous implementation cached
`volumeFurniUpdated` / `volumeTraxUpdated` BEFORE writing the new
values, but read `castedEvent.volumeFurni` / `castedEvent.volumeTrax`
in their pre-division form — comparing percent (e.g. 75) against the
already-divided stored value (e.g. 0.75) — so the change check almost
always reported "updated" for a real settings push and never reported
"updated" if the percent matched the stored fraction by coincidence
(only 0/100 are stable). Updated check is now consistent (compare
fraction vs fraction) and also tracks systemVolume changes for the
new snapshot invalidation.
Extends the snapshot pattern to the room's user list. The React client
currently has many widgets each calling `getUserDataByIndex(idx)` in a
loop (chat, doorbell, room player list, infostand …) — every render
walks the underlying Map and rebuilds an array. With
`getRoomUserListSnapshot(): ReadonlyArray<IRoomUserData>` consumers can
memoize on the array reference and only rebuild when something actually
changed.
Invalidation fires on every state-changing path:
- updateUserData (add/replace)
- removeUserData (leave)
- updateFigure / updateName / updateMotto / updateNickIcon /
updateCustomization / updateBackground / updateAchievementScore /
updatePetLevel / updatePetBreedingStatus
The inner IRoomUserData objects keep their existing in-place mutation
semantics (deep-clone would be too expensive for 30+ avatars on every
single status push). Consumers should treat each entry as a
snapshot-at-time-of-read and not retain references across an
invalidation.
New event: NitroEventType.ROOM_USER_LIST_UPDATED. Interface and event
additions are backwards-compatible; no existing accessors changed.
Also tidied: `updatePetLevel` now uses the explicit `if(!userData)
return;` guard pattern matching the rest of the methods (was a one-line
inline conditional).
Extends the v2.1.0 React-friendly snapshot pattern (originally on
SessionDataManager / RoomSessionManager) to two more session-state
holders the React client reads frequently:
- IgnoredUsersManager.getIgnoredUsersSnapshot(): ReadonlyArray<string>
- GroupInformationManager.getGroupBadgesSnapshot(): ReadonlyMap<number, string>
Both follow the same shape: lazy-frozen snapshot, cached until the
underlying state mutates, then invalidated and a dispatched event lets
the React client rebuild via useSyncExternalStore.
Two new NitroEventType members carry the invalidation signal:
- IGNORED_USERS_UPDATED — dispatched by IgnoredUsersManager whenever
the list changes (initial load, add, remove, queue-truncate case 2).
- GROUP_BADGES_UPDATED — dispatched by GroupInformationManager only
when the incoming HabboGroupBadges payload contains at least one
new or changed mapping (no-op refresh stays quiet).
This lets the user-info popup, profile page, friend/guild filtering,
and any other consumer share a single read through useSyncExternalStore
instead of each subscribing to the underlying message events
independently.
API additions are interface-respecting and backwards-compatible — the
existing `isIgnored(name)` / `getGroupBadge(groupId)` accessors stay
untouched.
Cover every public method on the binary pair, plus the typical packet
shape (header + mixed payload) the composer/parser pipeline emits:
- byte / short / int round-trips, including signed-edge values
(int8 -1 from 0xFF, int16 / int32 boundaries)
- big-endian wire-order assertions on writeShort / writeInt (matches
Arcturus's DataInputStream)
- string round-trip with length prefix + bare (includeLength=false)
+ UTF-8 multibyte byte count + empty-string edge
- writeBytes for both number[] and ArrayBuffer payloads
- readBytes slice returns an independent reader whose position is
decoupled from the outer reader
- remaining() decrements correctly across mixed-size reads
- readFloat / readDouble decode IEEE-754 big-endian values
(the writer has no float/double counterparts — buffer is built via
DataView for these cases)
- writer position getter + explicit setter (caller-managed reposition)
- two independent writers concatenate cleanly into a single reader
Suite: 127/127 (was 104/104). typecheck clean.
Adds three explicit parsing strategies selectable at host build time via
the compile-time constant __NITRO_JSON_MODE__:
- legacy: strict JSON.parse only; clear error suggesting JSON5 mode
- json5 : JSON5.parse only
- auto : try JSON, fall back to JSON5 (existing behaviour and default
when the flag is undefined, so older hosts keep working)
URL/MIME hints for .json5 sources are still respected. README updated
with the modes table and a Vite wiring example.
Adds a 'Recent renderer changes' section to CLAUDE.md covering all
the non-v2.1.0 work that landed during the React 19 modernization
round:
- RoomEnterComposer optional spawnX/spawnY (matches Arcturus'
RequestRoomLoadEvent optional tail).
- RoomSettingsData.allowUnderpass field + parser tail-int + composer
optional arg (Arcturus already emits the int).
- Dropped dead sendWhisperGroupMessage / ChatWhisperGroupComposer.
- TS 5.7+ ArrayBuffer drift handling and Pixi v8 narrows
(FurnitureBadgeDisplayVisualization signature realignment,
WebGLRenderer cast in ExtendedSprite, Filter[] union in AvatarImage,
ImageLike cast in TextureUtils, NitroConfig Window-decl unification,
empty-tuple composers).
- PetBreedingMessageParser bytesAvailable bool-vs-number bug fix.
Also adds two gotchas: 'bytesAvailable is a boolean' (was hit by
PetBreeding) and 'composer getMessageArray return type must match
the type argument' (was hit by both Wired*RequestComposer).
Arcturus' RoomSettingsComposer appends an extra int at the end of the
payload — room.isAllowUnderpass() ? 1 : 0 — and RoomSettingsSaveEvent
optionally reads back a boolean at the end (if bytesAvailable > 0).
The renderer side never modeled this trailing field, so the client
couldn't surface or persist it.
- RoomSettingsData: add _allowUnderpass field + getter/setter +
propagation through the .from() copy.
- RoomSettingsDataParser: read one trailing int after the moderation
settings, guarded by 'if(wrapper.bytesAvailable)' so older servers
that don't emit it keep parsing cleanly.
- SaveRoomSettingsComposer: optional trailing allowUnderpass arg. The
server's optional-read guard tolerates 24-arg or 25-arg payloads, so
callers that don't care about the field still send the legacy shape.
Cross-repo reference points:
- Arcturus emit side: Emulator/src/main/java/com/eu/habbo/messages/
outgoing/rooms/RoomSettingsComposer.java line 55.
- Arcturus read side: Emulator/src/main/java/com/eu/habbo/messages/
incoming/rooms/RoomSettingsSaveEvent.java lines 133-135.
Net client tsgo error count: 3 -> 0 on the NavigatorRoomSettings cluster.
Two tsgo nits that propagate to the client when the renderer is linked
in:
- packages/utils/src/NitroConfig.ts declared
'NitroConfig?: { [index: string]: any }' on Window, but Nitro-V3
declares 'NitroConfig?: Record<string, unknown>' in its
react-app-env.d.ts. The two declaration-merging fragments must
match — switching the renderer side to Record<string, unknown>
unifies them.
- packages/assets/src/AssetManager.ts: 'import.meta.glob(...)' is
augmented as Record<string, string> in the client's typedef, so
'mod.default ?? mod' (defensive handling of either string or
{ default: string }) failed because mod is typed string. Cast
inline: '((mod as { default?: string }).default ?? mod)'.
Renderer tsgo error count: 3 -> 0.
- SocketConnection.processMessage() did 'new events[0].parserClass()'
where parserClass is typed as 'Function' on IMessageEvent (no
construct signature). Cast to 'new () => IMessageParser' at the
call site so the spawned instance is type-correct downstream.
- RoomChatHandler dispatched RoomSessionChatEvent with the args in
the wrong order: '[]' (intended as the 'links' array) was landing
in the 'chatColours' string slot. Swap to '"", []' so links go
to position 8 and chatColours stays a string.
Four sites where Pixi v8's stricter typing tripped tsgo:
- AvatarImage: container.filters is typed as 'readonly Filter[] | null'
in v8 (no longer a single-Filter union). The old fallback branch
'else container.filters = [container.filters, …]' tried to treat a
readonly array as a single Filter; collapsed to the array-spread
path which now covers both undefined and non-empty cases. Added
Filter to the pixi.js import.
- FurnitureBadgeDisplayVisualization.updateSprite() had a 4-arg
override (sprite, asset, scale, layerId) of the parent's 2-arg
signature (scale, layerId). The sprite/asset were never used from
the parameters — the body only mutated 'sprite'. Refactored to
fetch the sprite via this.getSprite(layerId) inside the override
body so the signature matches the base.
- ExtendedSprite: 'renderer.gl' / 'glRenderTarget.resolveTargetFramebuffer'
exist only on WebGLRenderer / GlRenderTarget (not the WebGPU
variants). The runtime check 'renderer.type === RendererType.WEBGL'
guarantees this; cast at the boundary to satisfy the typechecker.
- TextureUtils.generateImage: Pixi v8's Extractor.image() returns the
union ImageLike (HTMLCanvasElement | HTMLImageElement); the public
signature promises HTMLImageElement. Cast at return — the Pixi
default backend returns HTMLImageElement here.
Arcturus' RequestRoomLoadEvent reads the two extra ints only when
the inbound packet has 8+ bytes remaining after roomId+password, so
the renderer can send 2-arg or 4-arg payloads against the same
header. The client already calls 'new RoomEnterComposer(roomId,
password, spawnX, spawnY)' in two places inside RoomSession /
RoomSessionManager (the reconnect/respawn flow) — the composer
signature is what was lagging behind.
Server-side reference:
Arcturus-Morningstar-Extended/Emulator/src/main/java/com/eu/habbo/
messages/incoming/rooms/RequestRoomLoadEvent.java
bytesAvailable is a boolean (IMessageDataWrapper.bytesAvailable: boolean,
returns 'there is at least one byte left'); the parser was doing
'wrapper.bytesAvailable < 12' as if it were a count, which both
mis-compares boolean to number and short-circuits incorrectly when
exactly 11 bytes remain.
Align with every other parser in the codebase: 'if(!wrapper ||
!wrapper.bytesAvailable) return false;'. The downstream readInt
calls already throw on truncated packets so the explicit length
check was load-bearing only against malformed inputs that wouldn't
parse anyway.
WiredRoomSettingsRequestComposer and WiredUserVariablesRequestComposer
declared 'implements IMessageComposer<ConstructorParameters<typeof Self>>'
but neither defines a constructor, so ConstructorParameters resolved
to 'any[]' and getMessageArray() returning [] (any[]) failed the
narrower base-type signature () => [].
Both composers send zero payload; type as IMessageComposer<[]>
directly + annotate the return type.
IRoomSession.sendWhisperGroupMessage(userId) was declared in the
interface and implemented in RoomSession by sending 'new
ChatWhisperGroupComposer(userId)' — but no such composer class
exists in the renderer (the file was never created). The only
whisper composer is RoomUnitChatWhisperComposer, which takes
(recipientName, message, styleId), not a userId.
No client call site references sendWhisperGroupMessage (grep across
Nitro-V3/src returned zero hits). Removing the dead interface method
+ broken impl is safer than inventing a ChatWhisperGroupComposer
class with no server-side handler.
TypeScript 5.7 split ArrayBuffer / SharedArrayBuffer at the type level
(ArrayBuffer now exposes resizable/transfer/detached etc; SharedArrayBuffer
doesn't), and parametrized the typed-array constructors so plain
Uint8Array became Uint8Array<ArrayBufferLike>.
The renderer never uses SharedArrayBuffer, so this is type-level only —
narrowing back to ArrayBuffer at the boundaries:
- BinaryReader.readBytes() / .toArrayBuffer() return the underlying
DataView buffer; cast to ArrayBuffer.
- BinaryWriter.getBuffer() same shape.
- WsSessionCrypto.randomNonce() now returns Uint8Array<ArrayBuffer>
(it's always backed by a plain ArrayBuffer); aesGcmEncrypt/Decrypt
nonce parameter retyped accordingly so SubtleCrypto.encrypt accepts
it as BufferSource.
- ArrayBufferToBase64 now accepts Uint8Array | ArrayBufferLike directly
(pako/inflate hands back Uint8Array<ArrayBuffer> which the old
ArrayBuffer-only signature rejected).
The IRoomSession interface was missing three things that have always
existed on the RoomSession implementation:
- `password: string` — the room session's join password (used by the
reconnect flow in RoomSessionManager).
- `sendBackgroundMessage(backgroundImage, backgroundStand, backgroundOverlay, backgroundCard?)`
— sends the profile-background composer (used by the React client's
BackgroundsView).
Plus a signature relaxation:
- `sendChatMessage` / `sendShoutMessage` `chatColour` is now optional.
The implementation already accepted `undefined` (the composer forwards
it through), and every historical call site in the React client passes
only 2 args — making the 3rd optional simply types reality.
Net renderer typecheck: 26 → 23.
The change also drops 7 errors on the consumer side
(see ../Nitro-V3 typecheck after the workspace link picks this up).
CLAUDE.md gotchas updated to reflect the new interface contract.
`AssetManager.loadRoomImages()` and friends use `import.meta.glob('./assets/...', { eager: true })`
to bundle PNG assets via Vite, but TypeScript doesn't see `glob` on
ImportMeta without pulling `vite/client` — which we avoid here so the
React client (which has its own asset declarations) keeps full control.
src/globals.d.ts adds just the `glob` signature, typed for the eager
image case (`Record<string, { default: string }>`). The call sites'
existing `mod.default ?? mod` narrowing still works.
Net renderer typecheck: 29 → 26 (-3 errors).
Each workspace package was still pinning `typescript: ~5.5.x` or
`~5.8.2` in its own devDependencies even though the root bumped to 6.0.3
in 60b1143. The pins were dead (yarn 1 hoists from root) but they're
misleading when reading a single package.json. Bring them all to
`^6.0.3` to match the root.
Other:
- @thumbmarkjs/thumbmarkjs 1.8.1 → 1.9.0 (root + communication package)
- yarn.lock regenerated from a clean install (vitest 4 hoisting was
flaking on the patch vite bump; reverted vite to ^8.0.10)
Adds CLAUDE.md at the repo root: short project context for future
sessions — stack, the 12-workspace layout, the React-friendly v2.1.0
additions (`subscribe()`, `subscribeMessage()`, snapshot getters), build
scripts, and known gotchas (`SessionDataManager.getUserData` does NOT
exist; sendChat* expects 3 args; dispatchEvent is sync).
- typescript: ~5.8.2 → ^6.0.3 (matches Nitro-V3 client)
- adds @typescript/native-preview (tsgo) as TypeScript 7 preview
- new `compile:fast` script using tsgo (~7× faster: 2.5s vs 17.6s)
- tsconfig cleanup ahead of TypeScript 7 deprecations:
- removed `baseUrl` (unused: no `paths` mappings on this project)
- removed `downlevelIteration` (target ES2022 makes it a no-op)
- `moduleResolution`: "Node" → "bundler" (vite consumes the renderer)
Compile errors: 28 → 29. Net +1 because TS 6's tightened lib types flag
two pre-existing crypto calls (WsSessionCrypto.ts:43,48) and resolves one
prior false positive. All errors are in pre-existing code, unrelated to
the new event/snapshot APIs from 791b8ad.