Document that the per-user borderId int in RoomUsersComposer (Arcturus Infostand Borders) must be read unconditionally inside the per-user loop in RoomUnitParser, never wrapped in a bytesAvailable guard. Calls out the failure modes on both server shapes (emits / doesn't emit) explicitly so the next contributor doesn't re-introduce the guard "for safety" the way it was earlier today. Points future trailing-int additions at the same parser block for documentation.
13 KiB
Nitro_Render_V3 — Claude project context
Pure-TypeScript renderer library for the Nitro retro Habbo client. Wraps PixiJS v8 for room/avatar rendering and provides the WebSocket
- event-bus infrastructure that the React client (
../Nitro-V3) sits on top of.
Stack
- TypeScript 6.0 (root) + tsgo (
@typescript/native-preview, TS 7 preview compiler — used byyarn compile:fast, ~7× faster on this codebase) - PixiJS v8 (
pixi.js@8.18) - Vite 8 for build + bundling
- Vitest 4 for unit tests
- Yarn 1.22 workspaces (
packages/*) — note: yarn 1, NOT yarn 4 like the client. The two repos use different package managers on purpose. - No React — this is a pure TS library; React lives in
../Nitro-V3.
Workspace layout
Twelve internal packages under packages/*/src/, each pinning
typescript: ^6.0.3 in its own devDependencies:
packages/
api public interfaces (IEventDispatcher, ISessionDataManager, ...)
assets asset loading + caching
avatar avatar rendering / figure resolution
camera in-room camera widget
communication WebSocket + composer/parser pipeline
configuration runtime config loader
events EventDispatcher + NitroEventType + per-domain events
localization LocalizationManager
room RoomEngine + RoomVisualization
session SessionDataManager + RoomSessionManager + handlers
sound SoundManager (howler-based)
utils shared utilities (BinaryReader, Logger, …)
Root index.ts re-exports everything from @nitrots/* so the React
client gets a flat import { … } from '@nitrots/nitro-renderer'.
React-friendly API additions (v2.1.0)
Three additions matter for the React client integration. Keep these backwards-compatible:
EventDispatcher.subscribe(type, callback): () => void
Signature matches what useSyncExternalStore expects — returns an
unsubscriber, no need to juggle callback identity. Implemented in
packages/events/src/EventDispatcher.ts. The legacy
addEventListener / removeEventListener still work.
CommunicationManager.subscribeMessage(eventCtor, handler): () => void
Equivalent for packet streams. Implemented in
packages/communication/src/CommunicationManager.ts.
Snapshot getters (referentially stable, lazy-frozen, invalidated on mutation)
Pattern: getXxxSnapshot() returns a frozen value cached internally;
mutators call invalidateXxxSnapshot() which drops the cache AND
dispatches an invalidation event. The React side reads via
useSyncExternalStore.
| Manager | Getter | Invalidation event |
|---|---|---|
SessionDataManager |
getUserDataSnapshot(): Readonly<IUserDataSnapshot> |
SESSION_DATA_UPDATED |
RoomSessionManager |
getActiveRoomSessionSnapshot(): Readonly<IRoomSessionSnapshot> | null |
ROOM_SESSION_UPDATED |
IgnoredUsersManager |
getIgnoredUsersSnapshot(): ReadonlyArray<string> |
IGNORED_USERS_UPDATED |
GroupInformationManager |
getGroupBadgesSnapshot(): ReadonlyMap<number, string> |
GROUP_BADGES_UPDATED (only on real changes — no-op refresh stays quiet) |
UserDataManager |
getRoomUserListSnapshot(): ReadonlyArray<IRoomUserData> |
ROOM_USER_LIST_UPDATED (inner IRoomUserData kept mutable — don't deep-clone) |
SoundManager |
getVolumesSnapshot(): Readonly<ISoundVolumesSnapshot> |
SOUND_VOLUMES_UPDATED (only when a volume actually changes) |
Snapshot interface contracts live under packages/api/src/nitro/session/
and packages/api/src/nitro/sound/. When adding a new snapshot, the
checklist is:
- Define the
Ixxx Snapshotinterface inpackages/api/src/nitro/...and export it from the matchingindex.ts. - Add a
XXX_UPDATEDmember topackages/events/src/NitroEventType.ts. - Add
getXxxSnapshot()to the interface AND impl; cache + invalidate on every mutation path (don't forget batch operations like queue truncation — invalidate AFTER the full batch, not mid-way).
Adding snapshots here is the preferred way to unblock new React widgets — prefer it over exposing raw event-listener APIs on the client side.
Recent renderer changes (feat/react19-event-bus)
Tracked separately from the v2.1.0 batch above; all are non-breaking additions or align-with-Arcturus fixes:
RoomEnterComposer: optional spawnX / spawnY
new RoomEnterComposer(roomId, password?, spawnX?, spawnY?). The
Arcturus RequestRoomLoadEvent handler reads the two extra ints only
when packet.remaining >= 8, so the same composer header serves both
the legacy 2-arg form (door spawn) and the 4-arg form (reconnect /
respawn at a specific tile). RoomSession + RoomSessionManager use the
4-arg variant in their enterRoom / reconnect paths.
RoomSettingsData: allowUnderpass field
RoomSettingsData (and its parser) now exposes allowUnderpass: boolean. Arcturus' RoomSettingsComposer already appends one
trailing int for this flag, and the new parser reads it via
if(wrapper.bytesAvailable) … readInt() === 1 so older servers that
don't emit the field still parse cleanly. SaveRoomSettingsComposer
accepts an optional allowUnderpass arg at the end of its parameter
list; the server-side RoomSettingsSaveEvent reads it under
packet.bytesAvailable() > 0.
RoomUnitParser per-user borderId (Infostand Borders wire contract)
RoomUsersComposer on Arcturus (post 54259f8 / fork commit 8f8f568
"Infostand Borders") appends appendInt(getInfostandBorder()) at the
end of EVERY user record — habbo, bot, rentable bot — using 0 as the
constant for records without a real border. To stay wire-aligned with
that, RoomUnitParser reads user.borderId = wrapper.readInt();
unconditionally inside the per-user loop, after
roomEntryMethod / roomEntryTeleportId.
DO NOT wrap this in a wrapper.bytesAvailable ? readInt() : 0 guard.
bytesAvailable is a boolean meaning "any bytes left in the WHOLE
packet?" — not "any bytes left for THIS user". For any non-last user
the guard evaluates true (next user's bytes follow) and reads, which
is fine ONLY by coincidence when the server emits borderId per user.
On a server that doesn't emit it, the guard steals 4 bytes from the
next record and cascade-corrupts the whole roster (symptom: users not
seeing each other on room enter). On a server that DOES emit it,
skipping the read leaves 4 unconsumed bytes per record and produces
the same corruption. Both shapes are wrong in a loop; unconditional
read paired with a server contract that always emits is the only
correct combination.
If you ever need to pair this parser with an older Arcturus that doesn't emit per-user borderId, the fix is on the server side (add the cherry-pick), not the parser side. Document any future trailing-int extension in this same place so the next reader doesn't re-introduce a bytesAvailable guard "for safety".
Dropped dead code: sendWhisperGroupMessage
IRoomSession.sendWhisperGroupMessage(userId) referenced a
ChatWhisperGroupComposer that never existed in the codebase and had
zero call sites in the React client. Both the interface declaration
and the broken impl are removed. The real whisper path is
RoomUnitChatWhisperComposer(recipientName, message, styleId) —
unchanged.
TS 5.7+ and Pixi v8 alignment
ArrayBufferLikedrift handled with explicit casts inBinaryReader/BinaryWriter/WsSessionCrypto.randomNonce()/ArrayBufferToBase64. The renderer never uses SharedArrayBuffer, so these are type-level narrowings only.Container.filtersin Pixi v8 isFilter[] | readonly Filter[] | null; the AvatarImage filter-stack mutation always goes through the spread-array branch now (no single-Filter fallback).Filteris imported explicitly from pixi.js.ExtendedSpritecasts the renderer toWebGLRendererinside theRendererType.WEBGLbranch sorenderer.gl/glRenderTarget.resolveTargetFramebufferresolve.FurnitureBadgeDisplayVisualization.updateSpritesignature realigned to the parent's 2-arg(scale, layerId)shape (was a custom 4-arg override that broke base-class assignability).TextureUtils.generateImagecasts the extractor'sImageLikeunion return toHTMLImageElement(the default backend produces one).Window.NitroConfigdeclaration inNitroConfig.tsrealigned to the client'sRecord<string, unknown>type so the merged decls agree.- Empty-tuple composers (
WiredRoomSettingsRequestComposer,WiredUserVariablesRequestComposer) annotate the return type(): []explicitly soIMessageComposer<[]>lines up.
Optional-trailing-field parsers: flat early-return chain
Parsers that read "one tier of optional trailing fields per emulator release" (UserProfileParser, GetGuestRoomResultMessageParser, RoomSettingsDataParser, ModeratorUserInfoData, UserSubscriptionParser …) all use a flat chain:
if(!wrapper.bytesAvailable) return true;
// block N reads
if(!wrapper.bytesAvailable) return true;
// block N+1 reads
…
Defaults come from flush(). When the next emulator release ships a
new trailing block, append if(!wrapper.bytesAvailable) return true;
- the new reads. Do NOT nest with
if(wrapper.bytesAvailable) { … }— the nested form re-indents the whole chain on every new tier and is the historical source of brittle reads.
Bug fix: SoundManager volume diff comparison
onEvent(SETTINGS_UPDATED) cached volumeFurniUpdated /
volumeTraxUpdated by comparing castedEvent.volumeFurni (percent,
e.g. 75) against this._volumeFurni (fraction, e.g. 0.75) — so the
change check almost always reported "updated" for a real settings push
and only reported "unchanged" if the percent matched the fraction by
coincidence (0 / 100 only). Fixed: divide first, compare divided
values, then write. Also tracks volumeSystemUpdated for the new
SOUND_VOLUMES_UPDATED snapshot invalidation.
Bug fix: PetBreedingMessageParser.bytesAvailable < 12
bytesAvailable is a boolean (the wrapper just answers "is there
anything left?"). The pet-breeding parser used to compare it against
12 as if it were a byte count, which TS 6 caught and which was
also semantically wrong. Replaced with the standard
if(!wrapper || !wrapper.bytesAvailable) return false; guard.
Scripts
yarn build # vite build
yarn compile # tsc --project ./tsconfig.json --noEmit false
yarn compile:fast # tsgo (~7× faster, TS 7 preview)
yarn eslint # lint src + packages/*/src
yarn test # vitest run
yarn test:watch # vitest watch
yarn test:coverage # vitest with v8 coverage
Consumed by
../Nitro-V3 consumes this library via link:../Nitro_Render_V3
(yarn 4 node-modules linker). DO NOT use yarn link — it confuses
vite's asset resolution. The client's vite.config.js then maps each
@nitrots/* package directly to its source index.ts so there's no
build step needed for development.
When making changes to renderer APIs the React client uses, the
client's feat/react19-* branches contain consumers — check
Nitro-V3/src/hooks/events/ and Nitro-V3/src/hooks/{session,rooms}/
for the React-side bridge code.
Gotchas
SessionDataManager.getUserData(id)does NOT exist. Some legacy code in the React client used it under agetUserData ?truthy guard; the branch was always dead. OnlygetUserDataSnapshot()exists.bytesAvailableis a boolean. The codebase historically had one parser (PetBreedingMessageParser) that compared it against a number — fixed. The wrapper returns "any bytes left?", not a count. Use it as a truthy guard or follow withtry {} catchif you need optional reads.- Composer
getMessageArray()return type must match the type argument.IMessageComposer<[]>means the function returns[], notany[]. The twoWired*RequestComposers that ship empty payloads each annotategetMessageArray(): []explicitly. IRoomSession.sendChatMessage/sendShoutMessageaccept an optionalchatColour3rd arg (was required pre-2.1.1, now optional to match the historical call sites in the React client). The implementation forwardsundefinedto the composer just fine; pass a value only when you need a specific bubble colour.IRoomSession.passwordandIRoomSession.sendBackgroundMessageare now part of the public interface (they always existed on the implementation class — interface caught up).- The renderer is synchronous:
EventDispatcher.dispatchEventis a synchronous loop over listeners. Don't addawaitinside theprocessEventloop — it would change ordering guarantees that consumers rely on. - Workspace package devDeps pin TS at
^6.0.3soyarn compileinside any single package keeps working. The root TS 6 is the source of truth.
Sister projects in the same DEV folder
../Nitro-V3— React 19 client (consumes this lib via link)../Arcturus-Morningstar-Extended— Java emulator (server side)../NitroV3-Housekeeping— Next.js + Prisma admin CMS