[notify] add documentation on push shapes

This commit is contained in:
Devin Zuczek
2026-08-11 11:36:51 -04:00
parent 21024c7852
commit 7df783302b
2 changed files with 678 additions and 2 deletions
+581
View File
@@ -0,0 +1,581 @@
/**
* The `Msg` bodies the client expects behind each {@link NotificationType}.
*
* Recovered from the client's Utf8Json generated formatters (see `recnet-patcher`,
* `il2cpp-tools/dtoshape.py`), not from a spec. Three properties of that decoder decide how
* much these matter:
*
* - **Every key is accepted in three casings** — Original, camelCase and all-lowercase. So
* `RoomId`, `roomId` and `roomid` are the same field. The spelling used below is the
* client's own canonical one.
* - **Unknown members are dropped in silence.** A typo'd key behaves exactly like an omitted
* one, which is why a wrong payload shows up as a blank UI rather than an error.
* - **A missing nested object is worse than a missing scalar.** Several handlers dereference
* one level down with no null guard, so omitting a nested field surfaces as a bare
* `NullReferenceException` in the client. Send a stub rather than nothing.
*
* Field *names* below are verified. Where a payload's type mapping could not be pinned down
* as confidently as its key list, the interface says so on the member.
*/
/** How a balance came to change. Log/telemetry only on the purchase frame — see below. */
export enum BalanceAddType {
Invalid = 0,
DirectBalanceWithMultiplier = 1,
FromGiftBox = 2,
NUXChallenge = 10,
AllNUXChallenges = 11,
DailyChallenge = 100,
AllDailyChallenges = 101,
FinishActivity = 200,
RecRoyaleMatchFinished = 250,
ChecklistCredit = 303,
WonGame = 1000,
LostGame = 1001,
WonGameRateLimited = 1002,
WonGamePartial = 1003,
LevelUp = 1100,
Registered = 1200,
CreatorReward = 1300,
CommercePurchase = 1400,
CommercePurchaseRevoked = 1401,
ManualRefund = 2000,
ManualThanks = 2010,
ManualApology = 2020,
}
/**
* Which store a balance belongs to. Note the **wire key is `Platform`, not `BalanceType`** —
* the client's property is called `BalanceType` but carries a `[DataMember]` rename, so
* `balanceType` on the wire is dropped and the balance silently reads as `SteamPurchased`.
*
* Balances are held per `(CurrencyType, Platform)` pair, so this also selects which bucket a
* balance frame updates. `RecNetPurchased` is the one to use for a self-hosted store.
*/
export enum BalancePlatform {
NonPurchasedNotUsableInP2P = -2,
NonPurchasedDefault = -1,
SteamPurchased = 0,
OculusPurchased = 1,
PlayStationPurchased = 2,
MicrosoftPurchased = 3,
RecNetPurchased = 4,
IOSPurchased = 5,
GooglePlayPurchased = 6,
PicoPurchased = 8,
PlayStationNonPurchasedP2P = 100,
NonPlayStationNonPurchasedP2P = 101,
NonPurchasedEarnedByP2P = 1000,
}
export enum CurrencyType {
Invalid = 0,
LaserTagTickets = 1,
RecCenterTokens = 2,
LostSkullsGold = 100,
DraculaSilver = 101,
RecRoyaleSeason1 = 200,
RoomCurrency = 300,
ProgressionEvent = 400,
}
/** Why a player was kicked/banned/warned. Shared by ModerationKick and ModerationUnkick. */
export enum KickReportCategory {
Moderator = -1,
Unknown = 0,
DeprecatedMicrophoneAbuse = 1,
Harassment = 2,
Cheating = 3,
DeprecatedImmatureBehavior = 4,
AFK = 5,
Misc = 6,
Underage = 7,
VoteKick = 10,
MisleadingPurchases = 11,
CoCUnderage = 100,
CoCSexual = 101,
CoCDiscrimination = 102,
CoCTrolling = 103,
CoCNameOrProfile = 104,
InappropriateClothing = 200,
IssuingInaccurateReports = 1000,
}
export enum LogoutReason {
Unknown = 0,
UserInitiated = 1,
SessionTakeover = 2,
ForciblyLoggedOut = 3,
Banned = 4,
}
/** The error the client renders when a `GoTo` fails. */
export enum GoToFailureError {
UnknownError = -1,
Success = 0,
NoSuchGame = 1,
PlayerNotOnline = 2,
InsufficientSpace = 3,
EventNotStarted = 4,
EventAlreadyFinished = 5,
BlockedFromRoom = 7,
JuniorNotAllowed = 11,
Banned = 12,
AlreadyInBestInstance = 13,
InsufficientRelationship = 14,
UpdateRequired = 16,
AlreadyInTargetInstance = 17,
UGCNotAllowed = 19,
NoSuchRoom = 20,
RoomIsNotActive = 22,
RoomBlockedByCreator = 23,
RoomIsPrivate = 25,
RoomInstanceIsPrivate = 26,
DeviceClassNotSupported = 30,
DeviceClassNotSupportedByRoomOwner = 31,
MovementModeNotSupportedByRoomOwner = 32,
EventIsPrivate = 35,
EventIsFull = 36,
RoomInviteExpired = 40,
NoAvailableRegion = 45,
}
// ---- Payloads ------------------------------------------------------------------
/** `StorefrontBalancePurchase` (62). */
export interface PurchaseBalanceModificationPayload {
BalanceAddType: BalanceAddType
/**
* The change, **for display only** — the client does not apply it. Its handler logs a
* line and then stores {@link Balance} outright, so a correct `Delta` with a stale
* `Balance` leaves the player's balance wrong.
*/
Delta: number
/** The post-transaction total. Absolute, and the only field that changes client state. */
Balance: number
Platform: BalancePlatform
CurrencyType: CurrencyType
}
/** `StorefrontBalanceUpdate` (61) — a bare set of one bucket to an absolute value. */
export interface BalanceResponsePayload {
Balance: number
CurrencyType: CurrencyType
Platform: BalancePlatform
}
/** One element of the `StorefrontBalanceAdd` (60) batch. */
export interface RewardBalanceModificationPayload {
BalanceAddType: BalanceAddType
BaseAward: number
BonusAward: number
RateLimit: number
CurrentCount: number
Total: number
Platform: BalancePlatform
BalanceInGiftBox: boolean
}
/** `ConsumableMappingAdded` (70) / `ConsumableMappingRemoved` (71). */
export interface ConsumableMappingPayload {
Id: number
ConsumableItemDesc: string
Count: number
InitialCount: number
/** ISO-8601. */
CreatedAt: string
ActiveDurationMinutes: number | null
IsActive: boolean
IsTransferable: boolean
}
/** `ModerationKick` (22) and `ModerationUnkick`. Room bans go out as this with `IsBan`. */
export interface ModerationKickPayload {
ReportCategory: KickReportCategory
/** Seconds. */
Duration: number
GameSessionId: number
IsHostKick: boolean
Message: string
PlayerIdReporter: number | null
IsBan: boolean
IsVoiceModAutoban: boolean
IsWarning: boolean
VoteKickReason: string
/** ISO-8601. */
TimeoutStartedAt: string | null
}
/** `ModerationKickAttemptFailed` (23) — a vote-kick that didn't carry. */
export interface ModerationKickFailedPayload {
ReportCategory: KickReportCategory
YesVotes: number
NoVotes: number
PlayerIdReported: number
}
/** `ServerMaintenance` (25). */
export interface ServerMaintenancePayload {
StartsInMinutes: number
}
/** `Logout` (6). */
export interface LogoutPayload {
Reason: LogoutReason
}
/** `MessageDeleted` (3) — the id of the message to drop. */
export interface MessageDeletedPayload {
Id: number
}
/** `MessageReceived` (2). */
export interface MessageReceivedPayload {
Id: number
FromPlayerId: number
/** ISO-8601. */
SentTime: string
Type: number
Data: string
RoomId: number | null
PlayerEventId: number | null
}
/** `RelationshipChanged` (1). */
export interface RelationshipChangedPayload {
/** Note the spelling — capital `ID`, unlike every other id key on the wire. */
PlayerID: number
RelationshipType: number
Muted: boolean
Ignored: boolean
Favorited: boolean
}
/** `PlayerEventDeleted` (82) / `PlayerEventResponseDeleted` (84). */
export interface PlayerEventIdPayload {
PlayerEventId: number
}
/** `PlayerEventResponseChanged` (83). */
export interface PlayerEventResponsePayload {
PlayerEvent: Record<string, unknown>
PlayerEventResponse: Record<string, unknown>
}
/** `PlayerEventCreated` (80) / `PlayerEventUpdated` (81). */
export interface PlayerEventPayload {
Tags: unknown[]
PlayerEventId: number
CreatorPlayerId: number
RoomId: number
SubRoomId: number | null
ClubId: number | null
Name: string
Description: string
ImageName: string
/** ISO-8601. */
StartTime: string
/** ISO-8601. */
EndTime: string
AttendeeCount: number
Accessibility: number
IsMultiInstance: boolean
SupportMultiInstanceRoomChat: boolean
DefaultBroadcastPermissions: number
CanRequestBroadcastPermissions: number
BroadcastingRoomInstanceId: number | null
}
/** `PlayerProgressionLevelUpdate`. `XP` is progress into the level, not a lifetime total. */
export interface PlayerProgressionLevelPayload {
PlayerId: number
Level: number
XP: number
}
/** `ProgressionEventsRecordUpdate`. */
export interface ProgressionEventRecordPayload {
AccountId: number
Xp: number
GameMinutesToday: number
RewardsCollected: number
BonusRewardsCollected: number
/** ISO-8601. */
XpBoostLastPurchasedAt: string | null
}
/**
* `SubscriptionUpdateProfile` (`"AccountUpdate"`) — the public projection of an account.
* The `Obscured*` CodeStage wrappers on the client side serialise as their plain underlying
* value, so nothing special is needed on the wire.
*/
export interface AccountUpdatePayload {
AccountId: number
UserName: string
DisplayName: string
DisplayEmoji: string
ProfileImage: string
BannerImage: string
TreatAsJunior: boolean
HasBirthday: boolean
PersonalPronouns: number
IdentityFlags: number
/** Lowercase-camel on the client's canonical spelling, unlike its neighbours. */
createdAt: string
IsJunior: boolean | null
}
/**
* `SubscriptionUpdateSelfProfile` (`"SelfAccountUpdate"`) — the owner-only projection: the
* six private fields **first**, then every field of {@link AccountUpdatePayload}. That order
* is not cosmetic; the client's decoder emits a derived DTO's own members before its base's.
*/
export interface SelfAccountUpdatePayload extends AccountUpdatePayload {
Email: string
Phone: string
/** ISO-8601. Its absence is what caused the under-13 junior crash. */
Birthday: string | null
JuniorState: number
ParentAccountId: number | null
AvailableUsernameChanges: number
}
/** `ChatMessageReceived` and `PlayerLeftChat` — same shape. */
export interface ChatMessagePayload {
ChatMessageId: number
ChatThreadId: number
SenderPlayerId: number
/** ISO-8601. */
TimeSent: string
Contents: string
ModerationState: number
}
/** `ClubMembershipUpdate`. */
export interface ClubMembershipPayload {
ClubId: number
MembershipType: number
}
/** `CreatorClubSubscriptionUpdate`. */
export interface CreatorClubSubscriptionPayload {
CreatorAccountId: number
ClubId: number
MembershipType: number
}
/** `RoomCurrencyCreated` / `RoomCurrencyModified`. */
export interface RoomCurrencyPayload {
CurrencyId: string
RoomId: number | null
Name: string
Description: string
CurrencyType: CurrencyType
Limit: number
ImageName: string
/** ISO-8601. */
CreatedAt: string
/** ISO-8601. */
ModifiedAt: string
}
/** `RoomCurrencyDeleted`. */
export interface RoomCurrencyDeletedPayload {
CurrencyId: string
}
/** `LocalRoomKeyCreated` (120). */
export interface LocalRoomKeyPayload {
RoomKeyId: number
ReplicationId: string
RoomId: number
Name: string
Description: string
Price: number
PurchaseCurrencyId: string | null
/** ISO-8601. */
CreatedAt: string
ImageName: string
}
/** `LocalRoomKeyDeleted` (121). */
export interface LocalRoomKeyDeletedPayload {
RoomKeyId: number
}
/** `AnnouncementUpdate`. */
export interface AnnouncementPayload {
AnnouncementId: number
AnnouncementType: number
Title: string
Body: string
ImageName: string
LinkType: number
LinkName: string
LinkButtonLabel: string
LinkUri: string
Platform: number
/** ISO-8601. */
CreatedAt: string
}
/** `AnnouncementDelete`. */
export interface AnnouncementDeletePayload {
AnnouncementId: number
}
/** `CommunityBoardAnnouncementUpdate` (96) — the board's single current announcement. */
export interface CommunityBoardAnnouncementPayload {
Message: string
MoreInfoUrl: string
}
/** `ReputationUpdate`. */
export interface ReputationPayload {
AccountId: number
IsCheerful: boolean
SelectedCheer: number | null
CheerCredit: number
CheerGeneral: number
CheerHelpful: number
CheerCreative: number
CheerGreatHost: number
CheerSportsman: number
}
/** `PhotonAccessToken`. */
export interface PhotonAccessTokenPayload {
RoomInstanceId: number
PhotonAccessToken: string
Permissions: unknown[]
}
/** `KeepsakeInstanceAdded` / `KeepsakeInstanceRemoved`. */
export interface KeepsakeInstancePayload {
KeepsakeInstanceId: string
KeepsakeCategoryConfigId: number
PlacedByAccountId: number
RoomId: number
SubRoomId: number | null
}
/** `PlayerCustomAvatarItemModerated`. */
export interface CustomAvatarItemPayload {
CustomAvatarItemId: string
CreatorAccountId: number
Name: string
Description: string
Price: number
Accessibility: number
IsFeatured: boolean
BaseAvatarItemId: number | null
BaseAvatarItemColor: string
DesignFilename: string
ThumbnailImageFilename: string
/** ISO-8601. */
CreatedAt: string
/** ISO-8601. */
ModifiedAt: string
}
/** `GoToFailure`. */
export interface GoToFailurePayload {
Error: GoToFailureError
}
/** `AppVersionUpdate` — the live replacement for the dead `ModerationUpdateRequired` (21). */
export interface AppVersionUpdatePayload {
ActivePlatforms: unknown
}
/** `IncentivizedReferralUpdate`. */
export interface IncentivizedReferralPayload {
InviteeAccountId: number
/** ISO-8601. */
CreatedAt: string
/** ISO-8601. */
VerifiedAt: string | null
}
/** `InfluencerSupportedUpdate`. */
export interface InfluencerSupportedPayload {
SupportedInfluencerId: number | null
}
/** `StringAutoLocalizationJob`. */
export interface StringAutoLocalizationJobPayload {
Scope: string
Status: number
}
/** `SubscriptionUpdateGameSession` (`"RoomInstanceUpdate"`). */
export interface RoomInstanceUpdatePayload {
RoomInstanceId: number
RoomId: number
SubRoomId: number
Location: string
EventId: number
ClubId: number
RoomCode: string
/** Canonical spelling is lower-camel here, unlike its neighbours. */
photonRegionId: string
PhotonRoomId: string
Name: string
MaxCapacity: number
IsFull: boolean
IsPrivate: boolean
IsInProgress: boolean
EncryptVoiceChat: boolean
RoomInstanceType: number
MatchmakingPolicy: number
}
/**
* `GiftPackageReceived` (30), `GiftPackageReceivedImmediate` (31) and
* `GiftPackageRewardSelectionReceived` (32) all carry this. Note `BalanceType` here is NOT
* the renamed-to-`Platform` field seen on the balance payloads — it is its own member and
* keeps its name; `Platform` is a separate key on the same object.
*/
export interface GiftPackagePayload {
Id: number | null
FromPlayerId: number | null
ConsumableItemDesc: string
AvatarItemType: number | null
AvatarItemDesc: string
EquipmentPrefabName: string
EquipmentModificationGuid: string
CurrencyType: CurrencyType
Currency: number
Xp: number
GiftContext: number
GiftRarity: number
Message: string
Platform: number
PlatformsToSpawnOn: unknown
BalanceType: BalancePlatform | null
}
/** `gift.manualconsumed`. */
export interface GiftManualConsumedPayload {
GiftPackageId: number
}
/** `RewardSelectionReceived` — distinct from the gift-package frames above. */
export interface RewardSelectionPayload {
RewardSelectionId: number
RewardType: number
Message: string
GiftContext: number
GiftDrop1: unknown
GiftDrop2: unknown
GiftDrop3: unknown
Subscriber_GiftDrop3: unknown
/** ISO-8601. */
CreatedAt: string
}
/**
* Channels whose handler takes no argument at all — the client refetches rather than reading
* the frame. Send `{}`; anything else is ignored.
*/
export type NoPayload = Record<string, never>
+97 -2
View File
@@ -13,12 +13,32 @@
* Lives in the `notify` worker (the hub owner); other workers import it to send a
* typed notification instead of a magic number. No runtime dependencies, so it's safe
* to import as a value from another worker's bundle.
*
* ## How this was verified
*
* Every member below was checked against the client's own subscription table, recovered by
* static analysis of `GameAssembly.dll` (see `recnet-patcher`, `il2cpp-tools/regall.py`).
* The client registers each channel by key — a decimal string for numeric ids, a name for
* the rest — and **a key with no subscriber is dropped in silence**: the frame parses, no
* handler runs, nothing is logged. So "the client ignores this" is indistinguishable from
* "the server never sent it" at runtime, which is why the status is recorded here instead.
*
* Members marked `@deadOnClient` have no subscriber in the build this was taken from
* (20230414-era, `GameAssembly.dll` 156,069,888 bytes). They are kept because they name a
* real client enum member and may come back in another build — but sending one today is a
* no-op. Everything not so marked was confirmed to have a live handler.
*/
export enum NotificationType {
RelationshipChanged = 1,
MessageReceived = 2,
MessageDeleted = 3,
/**
* @deadOnClient No subscriber, and unlike its neighbours it has no wire-name twin either —
* the string `PresenceHeartbeat…` does not appear anywhere in the client's metadata. The
* heartbeat response has nowhere to land in this build.
*/
PresenceHeartbeatResponse = 4,
/** Also reachable as the wire name `"PlayerPrivileges.Refresh"` — same handler. */
RefreshLogin = 5,
Logout = 6,
SubscriptionUpdateProfile = 'AccountUpdate',
@@ -28,6 +48,9 @@ export enum NotificationType {
* sends both on connect and after a profile mutation — everyone gets the public frame,
* the owner additionally gets this one. Named after its twin rather than after a client
* enum member, since the client's enum doesn't list it; the WIRE name is what matters.
*
* Confirmed live: the client subscribes `"SelfAccountUpdate"` and the payload is the
* public account DTO plus six owner-only fields. See `SelfAccountUpdatePayload`.
*/
SubscriptionUpdateSelfProfile = 'SelfAccountUpdate',
SubscriptionUpdatePresence = 'PresenceUpdate',
@@ -39,16 +62,29 @@ export enum NotificationType {
* stringifies whatever it is given, so `15` would go out as the unrelated `"15"`.
*/
SubscriptionUpdateRoom = 'RoomUpdate',
/** Unverified: nothing sends it here, and the reference's hub never puts it on the wire. */
/** @deadOnClient No subscriber under `16` and no wire-name twin. */
SubscriptionUpdateRoomPlaylist = 16,
ModerationQuitGame = 20,
/**
* @deadOnClient Nothing listens on `21`. {@link AppVersionUpdate} is the live channel
* that does this job — same handler class, addressed by name.
*/
ModerationUpdateRequired = 21,
ModerationKick = 22,
ModerationKickAttemptFailed = 23,
/**
* @deadOnClient Not present in this build at all: no subscriber on `24`, and the string
* `"ModerationRoomBan"` does not exist in the client's metadata, so neither spelling can
* be dispatched. To ban from a room, use {@link ModerationKick} with `IsBan: true`.
*/
ModerationRoomBan = 'ModerationRoomBan',
ServerMaintenance = 25,
GiftPackageReceived = 30,
GiftPackageReceivedImmediate = 31,
/**
* Carries a gift package like its two neighbours. Distinct from
* {@link RewardSelectionReceived}, which is a different channel with a different payload.
*/
GiftPackageRewardSelectionReceived = 32,
/**
* A player's level/XP changed — `{ PlayerId, Level, XP }`, where XP is the progress into
@@ -56,9 +92,13 @@ export enum NotificationType {
* wire name and its enum has no number for this one at all. It pushes the frame both when
* progression changes and when the player reads it back, which is how a client that just
* connected gets its bar right.
*
* Confirmed live, and the three-field payload confirmed against the client's formatter.
*/
PlayerProgressionLevelUpdate = 'PlayerProgressionLevelUpdate',
/** @deadOnClient No subscriber under `40` and no wire-name twin. */
ProfileJuniorStatusUpdate = 40,
/** Takes no payload — the client refetches. */
RelationshipsInvalid = 50,
StorefrontBalanceAdd = 60,
StorefrontBalanceUpdate = 61,
@@ -70,12 +110,67 @@ export enum NotificationType {
PlayerEventDeleted = 82,
PlayerEventResponseChanged = 83,
PlayerEventResponseDeleted = 84,
/** @deadOnClient No subscriber under `85` and no wire-name twin. */
PlayerEventStateChanged = 85,
ChatMessageReceived = 'ChatMessageReceived',
CommunityBoardUpdate = 95,
/**
* STRING-valued, and this one is easy to get wrong: the client's enum *does* have a
* member numbered `95`, but nothing subscribes to `"95"` — the live subscription is on
* the name. Sending the number is a silent no-op.
*/
CommunityBoardUpdate = 'CommunityBoardUpdate',
/**
* Numeric, unlike its `CommunityBoard` sibling above — `96` genuinely has a subscriber.
* Do not "unify" these two; they were checked separately. Note the *announcement list*
* has its own name-addressed channels ({@link AnnouncementUpdate} /
* {@link AnnouncementDelete}); this one is the board's single current announcement.
*/
CommunityBoardAnnouncementUpdate = 96,
/** Takes no payload — the client refetches. */
InventionModerationStateChanged = 100,
/** Takes no payload — the client refetches. */
FreeGiftButtonItemsAdded = 110,
LocalRoomKeyCreated = 120,
LocalRoomKeyDeleted = 121,
// ---- Name-addressed channels with no client enum member ------------------
// These have no number at all: the client subscribes them purely by name. Grouped
// separately so the numeric block above stays a faithful mirror of the client enum.
/** Client version gate. The live replacement for {@link ModerationUpdateRequired}. */
AppVersionUpdate = 'AppVersionUpdate',
AnnouncementUpdate = 'AnnouncementUpdate',
AnnouncementDelete = 'AnnouncementDelete',
ClubMembershipUpdate = 'ClubMembershipUpdate',
CreatorClubSubscriptionUpdate = 'CreatorClubSubscriptionUpdate',
CommerceSubscriptionUpdate = 'CommerceSubscriptionUpdate',
/** Takes no payload — the client refetches `api/config/v2`. */
GameConfigRefresh = 'GameConfig.Refresh',
/** Takes no payload — the client refetches. */
PlayerSettingsRefresh = 'PlayerSettings.Refresh',
/** Matchmaking told the client its `GoTo` failed; payload is a single error code. */
GoToFailure = 'GoToFailure',
IncentivizedReferralUpdate = 'IncentivizedReferralUpdate',
InfluencerSupportedUpdate = 'InfluencerSupportedUpdate',
KeepsakeInstanceAdded = 'KeepsakeInstanceAdded',
KeepsakeInstanceRemoved = 'KeepsakeInstanceRemoved',
/** The un-kick; same payload type as {@link ModerationKick}. */
ModerationUnkick = 'ModerationUnkick',
/** Hands the client a Photon token for a room instance. */
PhotonAccessToken = 'PhotonAccessToken',
PlayerCustomAvatarItemModerated = 'PlayerCustomAvatarItemModerated',
/** Same payload type as {@link ChatMessageReceived}. */
PlayerLeftChat = 'PlayerLeftChat',
ProgressionEventsRecordUpdate = 'ProgressionEventsRecordUpdate',
ReputationUpdate = 'ReputationUpdate',
/** Distinct from {@link GiftPackageRewardSelectionReceived} — different payload. */
RewardSelectionReceived = 'RewardSelectionReceived',
RoomCommentDeleted = 'RoomCommentDeleted',
RoomCurrencyCreated = 'RoomCurrencyCreated',
RoomCurrencyModified = 'RoomCurrencyModified',
RoomCurrencyDeleted = 'RoomCurrencyDeleted',
StringAutoLocalizationJob = 'StringAutoLocalizationJob',
WebsiteInventionPurchase = 'WebsiteInventionPurchase',
GiftManualConsumed = 'gift.manualconsumed',
PushToDevice = 'rrs.pushtodevice',
}