Files
Nitro_Render_V3/CLAUDE.md
T

301 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 by `yarn 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:
1. Define the `Ixxx Snapshot` interface in `packages/api/src/nitro/...`
and export it from the matching `index.ts`.
2. Add a `XXX_UPDATED` member to `packages/events/src/NitroEventType.ts`.
3. 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
- `ArrayBufferLike` drift handled with explicit casts in `BinaryReader`
/ `BinaryWriter` / `WsSessionCrypto.randomNonce()` /
`ArrayBufferToBase64`. The renderer never uses SharedArrayBuffer, so
these are type-level narrowings only.
- `Container.filters` in Pixi v8 is `Filter[] | readonly Filter[] | null`;
the AvatarImage filter-stack mutation always goes through the
spread-array branch now (no single-Filter fallback). `Filter` is
imported explicitly from pixi.js.
- `ExtendedSprite` casts the renderer to `WebGLRenderer` inside the
`RendererType.WEBGL` branch so `renderer.gl` /
`glRenderTarget.resolveTargetFramebuffer` resolve.
- `FurnitureBadgeDisplayVisualization.updateSprite` signature realigned
to the parent's 2-arg `(scale, layerId)` shape (was a custom 4-arg
override that broke base-class assignability).
- `TextureUtils.generateImage` casts the extractor's `ImageLike`
union return to `HTMLImageElement` (the default backend produces
one).
- `Window.NitroConfig` declaration in `NitroConfig.ts` realigned to
the client's `Record<string, unknown>` type so the merged decls
agree.
- Empty-tuple composers (`WiredRoomSettingsRequestComposer`,
`WiredUserVariablesRequestComposer`) annotate the return type
`(): []` explicitly so `IMessageComposer<[]>` 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:
```ts
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 a `getUserData ?` truthy guard;
the branch was always dead. Only `getUserDataSnapshot()` exists.
- **`bytesAvailable` is 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 with `try {} catch` if you need
optional reads.
- **Composer `getMessageArray()` return type must match the type
argument.** `IMessageComposer<[]>` means the function returns `[]`,
not `any[]`. The two `Wired*RequestComposer`s that ship empty
payloads each annotate `getMessageArray(): []` explicitly.
- `IRoomSession.sendChatMessage` / `sendShoutMessage` accept an optional
`chatColour` 3rd arg (was required pre-2.1.1, now optional to match
the historical call sites in the React client). The implementation
forwards `undefined` to the composer just fine; pass a value only when
you need a specific bubble colour.
- `IRoomSession.password` and `IRoomSession.sendBackgroundMessage` are
now part of the public interface (they always existed on the
implementation class — interface caught up).
- The renderer is **synchronous**: `EventDispatcher.dispatchEvent` is a
synchronous loop over listeners. Don't add `await` inside the
`processEvent` loop — it would change ordering guarantees that
consumers rely on.
- Workspace package devDeps pin TS at `^6.0.3` so `yarn compile` inside
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
## Live furnidata updates: `FurnitureDataReload` (incoming header 10047)
Server-pushed furni name/description changes (pairs with Arcturus'
`FurnitureDataReloadComposer`). `SessionDataManager.applyFurnidataDelta` (pure
`applyFurnidataDeltaTo` in `packages/session/src/furniture/`) patches
`_floorItems`/`_wallItems` by id + the `roomItem/wallItem.name/desc.{id}`
localization keys, then dispatches the window event `nitro-localization-updated`
so the client's already-subscribed surfaces refresh. `mode` 0 = delta, 1 =
reload-hint (re-runs `FurnitureDataLoader.init()`). Kept SEPARATE from the
furni-editor's `applyLiveFurnitureNameUpdate`.
**Adding an incoming packet:** id in `IncomingHeader.ts` -> map in
`NitroMessages.ts` (`this._events.set(IncomingHeader.X, XEvent)`) -> Event +
Parser under `messages/incoming/<area>` + `messages/parser/<area>` -> wire the
barrel chain (`<area>/index.ts` -> parent `index.ts` -> package `src/index.ts`).
**Gotchas:**
- A branch based on `origin/Dev` may NOT contain the furni-editor slice
(`FurniDataUpdatedEvent` / `applyLiveFurnitureNameUpdate`) — verify, don't assume.
- Building the renderer in a fresh git worktree needs its own `yarn install`.