diff --git a/packages/common/enums/osu.ts b/packages/common/enums/osu.ts index da72c04b..4d3e1261 100644 --- a/packages/common/enums/osu.ts +++ b/packages/common/enums/osu.ts @@ -236,7 +236,6 @@ export enum LazerSettings { Skin, ScreenshotFormat, ScreenshotCaptureMenuCursor, - SongSelectRightMouseScroll, BeatmapSkins, BeatmapColours, BeatmapHitsounds, @@ -254,8 +253,10 @@ export enum LazerSettings { IntroSequence, NotifyOnUsernameMentioned, NotifyOnPrivateMessage, + NotifyOnFriendPresenceChange, UIHoldActivationDelay, HitLighting, + StarFountains, MenuBackgroundSource, GameplayDisableWinKey, SeasonalBackgroundMode, @@ -263,7 +264,6 @@ export enum LazerSettings { EditorShowHitMarkers, EditorAutoSeekOnPlacement, DiscordRichPresence, - AutomaticallyDownloadWhenSpectating, ShowOnlineExplicitContent, LastProcessedMetadataId, SafeAreaConsiderations, @@ -280,5 +280,18 @@ export enum LazerSettings { MultiplayerRoomFilter, HideCountryFlags, EditorTimelineShowTimingChanges, - EditorTimelineShowTicks + EditorTimelineShowTicks, + AlwaysShowHoldForMenuButton, + EditorContractSidebars, + EditorScaleOrigin, + EditorRotationOrigin, + EditorTimelineShowBreaks, + EditorAdjustExistingObjectsOnTimingChanges, + AlwaysRequireHoldingForPause, + MultiplayerShowInProgressFilter, + BeatmapListingFeaturedArtistFilter, + ShowMobileDisclaimer, + EditorShowStoryboard, + EditorSubmissionNotifyOnDiscussionReplies, + EditorSubmissionLoadInBrowserAfterSubmission } diff --git a/packages/tosu/src/api/utils/buildResult.ts b/packages/tosu/src/api/utils/buildResult.ts index 50d99082..0546e6f5 100644 --- a/packages/tosu/src/api/utils/buildResult.ts +++ b/packages/tosu/src/api/utils/buildResult.ts @@ -1,4 +1,4 @@ -import { ClientType, GameState } from '@tosu/common'; +import { ClientType, CountryCodes, GameState } from '@tosu/common'; import path from 'path'; import { @@ -7,10 +7,13 @@ import { TourneyIpcClient, TourneyValues } from '@/api/types/v1'; +import { LazerInstance } from '@/instances/lazerInstance'; import { InstanceManager } from '@/instances/manager'; +import { IUserProtected } from '@/memory/types'; import { LeaderboardPlayer as MemoryLeaderboardPlayer } from '@/states/types'; -import { calculateAccuracy } from '@/utils/calculators'; +import { calculateAccuracy, calculateGrade } from '@/utils/calculators'; import { fixDecimals } from '@/utils/converters'; +import { CalculateMods } from '@/utils/osuMods.types'; const convertMemoryPlayerToResult = ( memoryPlayer: MemoryLeaderboardPlayer @@ -263,7 +266,136 @@ export const buildResult = (instanceManager: InstanceManager): ApiAnswer => { rawBanchoStatus: user.rawBanchoStatus, backgroundColour: user.backgroundColour?.toString(16) }, - tourney: buildTourneyData(instanceManager) + tourney: + osuInstance instanceof LazerInstance && global.isMultiSpectating + ? buildLazerTourneyData(osuInstance) + : buildTourneyData(instanceManager) + }; +}; + +const buildLazerTourneyData = ( + osuInstance: LazerInstance +): TourneyValues | undefined => { + const { global, lazerMultiSpectating } = osuInstance.getServices([ + 'global', + 'lazerMultiSpectating' + ]); + + if (!lazerMultiSpectating.lazerSpectatingData) { + return undefined; + } + + return { + manager: { + ipcState: global.status, + bestOF: 0, + teamName: { + left: '', + right: '' + }, + stars: { + left: 0, + right: 0 + }, + bools: { + scoreVisible: global.status === GameState.lobby, + starsVisible: false + }, + chat: [], + gameplay: { + score: { + left: lazerMultiSpectating.lazerSpectatingData.spectatingClients + .filter((client) => client.team === 'red') + .reduce((pv, cv) => (pv += cv.score?.score || 0), 0), + right: lazerMultiSpectating.lazerSpectatingData.spectatingClients + .filter((client) => client.team === 'blue') + .reduce((pv, cv) => (pv += cv.score?.score || 0), 0) + } + } + }, + ipcClients: + lazerMultiSpectating.lazerSpectatingData.spectatingClients.map( + (client) => { + const currentMods = + global.status === GameState.play + ? client.score!.mods + : global.status === GameState.resultScreen + ? ((client.resultScreen! as any) + .mods as CalculateMods) + : global.menuMods; + + const currentGrade = calculateGrade({ + isLazer: true, + mods: client.score!.mods.number, + mode: client.score!.mode, + hits: { + 300: client.score!.hit300, + geki: 0, + 100: client.score!.hit100, + katu: 0, + 50: client.score!.hit50, + 0: client.score!.hitMiss + } + }); + + return { + team: client.team === 'red' ? 'left' : 'right', + spectating: { + name: client.user.name, + country: + CountryCodes[ + (client.user as IUserProtected).countryCode + ]?.toUpperCase() || '', + userID: (client.user as IUserProtected).id, + accuracy: (client.user as IUserProtected).accuracy, + rankedScore: (client.user as IUserProtected) + .rankedScore, + playCount: (client.user as IUserProtected) + .playCount, + globalRank: (client.user as IUserProtected).rank, + totalPP: (client.user as IUserProtected) + .performancePoints + }, + gameplay: { + gameMode: client.score!.mode, + name: client.score!.playerName, + score: client.score!.score, + accuracy: client.score!.accuracy, + combo: { + current: client.score!.combo, + max: client.score!.maxCombo + }, + hp: { + normal: client.score!.playerHP, + smooth: client.score!.playerHPSmooth + }, + hits: { + 300: client.score!.hit300, + geki: client.score!.hitGeki, + 100: client.score!.hit100, + katu: client.score!.hitKatu, + 50: client.score!.hit50, + 0: client.score!.hitMiss, + // TODO: ADD SLIDERBREAKS + sliderBreaks: 0, + grade: { + current: currentGrade, + // not supported + maxThisPlay: '' + }, + // not supported + unstableRate: 0, + // not supported + hitErrorArray: [] + }, + mods: { + num: currentMods.number, + str: currentMods.name + } + } + }; + } + ) }; }; diff --git a/packages/tosu/src/api/utils/buildResultV2.ts b/packages/tosu/src/api/utils/buildResultV2.ts index ab1f4aed..39d7ca40 100644 --- a/packages/tosu/src/api/utils/buildResultV2.ts +++ b/packages/tosu/src/api/utils/buildResultV2.ts @@ -22,7 +22,9 @@ import { TourneyChatMessages, TourneyClients } from '@/api/types/v2'; +import { LazerInstance } from '@/instances/lazerInstance'; import { InstanceManager } from '@/instances/manager'; +import { IUserProtected } from '@/memory/types'; import { BeatmapPP } from '@/states/beatmap'; import { Gameplay } from '@/states/gameplay'; import { LeaderboardPlayer as MemoryLeaderboardPlayer } from '@/states/types'; @@ -332,7 +334,229 @@ export const buildResult = (instanceManager: InstanceManager): ApiAnswer => { skinFolder: global.skinFolder }, - tourney: buildTourneyData(instanceManager) + tourney: + osuInstance instanceof LazerInstance && global.isMultiSpectating + ? buildLazerTourneyData(osuInstance) + : buildTourneyData(instanceManager) + }; +}; + +const buildLazerTourneyData = ( + osuInstance: LazerInstance +): Tourney | undefined => { + const { global, lazerMultiSpectating } = osuInstance.getServices([ + 'global', + 'lazerMultiSpectating' + ]); + + if (!lazerMultiSpectating.lazerSpectatingData) { + return undefined; + } + + return { + scoreVisible: global.status === GameState.lobby, + starsVisible: false, + + ipcState: global.status, + bestOF: 0, + team: { + left: '', + right: '' + }, + + points: { + left: 0, + right: 0 + }, + + chat: [], + + totalScore: { + left: lazerMultiSpectating.lazerSpectatingData.spectatingClients + .filter((client) => client.team === 'red') + .reduce((pv, cv) => (pv += cv.score?.score || 0), 0), + right: lazerMultiSpectating.lazerSpectatingData.spectatingClients + .filter((client) => client.team === 'blue') + .reduce((pv, cv) => (pv += cv.score?.score || 0), 0) + }, + + clients: lazerMultiSpectating.lazerSpectatingData.spectatingClients.map( + (client, index) => { + const currentGrade = calculateGrade({ + isLazer: true, + mods: client.score!.mods.number, + mode: client.score!.mode, + hits: { + 300: client.score!.hit300, + geki: 0, + 100: client.score!.hit100, + katu: 0, + 50: client.score!.hit50, + 0: client.score!.hitMiss + } + }); + + const currentMods = + global.status === GameState.play + ? client.score!.mods + : global.status === GameState.resultScreen + ? ((client.resultScreen! as any) + .mods as CalculateMods) + : global.menuMods; + + return { + ipcId: index, + team: client.team === 'red' ? 'left' : 'right', + + user: { + id: (client.user as IUserProtected).id, + name: client.user.name, + country: + CountryCodes[ + (client.user as IUserProtected).countryCode + ]?.toUpperCase() || '', + accuracy: (client.user as IUserProtected).accuracy, + rankedScore: (client.user as IUserProtected) + .rankedScore, + playCount: (client.user as IUserProtected).playCount, + globalRank: (client.user as IUserProtected).rank, + totalPP: (client.user as IUserProtected) + .performancePoints + }, + + beatmap: { + stats: { + // not supported start + stars: { + live: 0, + aim: 0, + speed: 0, + flashlight: 0, + sliderFactor: 0, + stamina: 0, + rhythm: 0, + color: 0, + reading: 0, + hitWindow: 0, + total: 0 + }, + + ar: { + original: 0, + converted: 0 + }, + cs: { + original: 0, + converted: 0 + }, + od: { + original: 0, + converted: 0 + }, + hp: { + original: 0, + converted: 0 + }, + + bpm: { + realtime: 0, + common: 0, + min: 0, + max: 0 + }, + + objects: { + circles: 0, + sliders: 0, + spinners: 0, + holds: 0, + total: 0 + }, + + maxCombo: 0 + // not supported end + } + }, + + play: { + playerName: client.score!.playerName, + + mode: { + number: client.score!.mode, + name: Rulesets[client.score!.mode] || '' + }, + + score: client.score!.score, + accuracy: client.score!.accuracy, + + healthBar: { + normal: (client.score!.playerHP / 200) * 100, + smooth: (client.score!.playerHPSmooth / 200) * 100 + }, + + hits: { + 300: client.score!.hit300, + geki: client.score!.hitGeki, + 100: client.score!.hit100, + katu: client.score!.hitKatu, + 50: client.score!.hit50, + 0: client.score!.hitMiss, + sliderEndHits: client.score!.sliderEndHits, + smallTickHits: client.score!.smallTickHits, + largeTickHits: client.score!.largeTickHits, + // TODO: ADD SLIDERBREAKS + sliderBreaks: 0 + }, + + // not supported + hitErrorArray: [], + + combo: { + current: client.score!.combo, + max: client.score!.maxCombo + }, + mods: { + checksum: currentMods.checksum, + number: currentMods.number, + name: currentMods.name, + array: currentMods.array, + rate: currentMods.rate + }, + rank: { + current: currentGrade, + maxThisPlay: currentGrade + }, + + // not supported start + pp: { + current: fixDecimals(client!.score!.pp || 0), + fc: 0, + maxAchievedThisPlay: 0, + detailed: { + current: { + aim: 0, + speed: 0, + accuracy: 0, + difficulty: 0, + flashlight: 0, + total: 0 + }, + fc: { + aim: 0, + speed: 0, + accuracy: 0, + difficulty: 0, + flashlight: 0, + total: 0 + } + } + }, + unstableRate: 0 + // not supported end + } + }; + } + ) }; }; diff --git a/packages/tosu/src/instances/index.ts b/packages/tosu/src/instances/index.ts index a1c0abf8..e96cbc6a 100644 --- a/packages/tosu/src/instances/index.ts +++ b/packages/tosu/src/instances/index.ts @@ -13,6 +13,7 @@ import { BassDensity } from '@/states/bassDensity'; import { BeatmapPP } from '@/states/beatmap'; import { Gameplay } from '@/states/gameplay'; import { Global } from '@/states/global'; +import { LazerMultiSpectating } from '@/states/lazerMultiSpectating'; import { Menu } from '@/states/menu'; import { ResultScreen } from '@/states/resultScreen'; import { Settings } from '@/states/settings'; @@ -31,6 +32,7 @@ export interface DataRepoList { resultScreen: ResultScreen; tourneyManager: TourneyManager; user: User; + lazerMultiSpectating: LazerMultiSpectating; } export abstract class AbstractInstance { @@ -79,6 +81,7 @@ export abstract class AbstractInstance { this.set('resultScreen', new ResultScreen(this)); this.set('tourneyManager', new TourneyManager(this)); this.set('user', new User(this)); + this.set('lazerMultiSpectating', new LazerMultiSpectating(this)); this.watchProcessHealth = this.watchProcessHealth.bind(this); this.preciseDataLoop = this.preciseDataLoop.bind(this); diff --git a/packages/tosu/src/instances/lazerInstance.ts b/packages/tosu/src/instances/lazerInstance.ts index 23c6e91f..271b6775 100644 --- a/packages/tosu/src/instances/lazerInstance.ts +++ b/packages/tosu/src/instances/lazerInstance.ts @@ -27,18 +27,26 @@ export class LazerInstance extends AbstractInstance { async regularDataLoop(): Promise { wLogger.debug(ClientType[this.client], this.pid, 'regularDataLoop'); - const { global, menu, beatmapPP, gameplay, resultScreen, user } = - this.getServices([ - 'global', - 'menu', - 'bassDensity', - 'beatmapPP', - 'gameplay', - 'resultScreen', - 'settings', - 'tourneyManager', - 'user' - ]); + const { + global, + menu, + beatmapPP, + gameplay, + resultScreen, + user, + lazerMultiSpectating + } = this.getServices([ + 'global', + 'menu', + 'bassDensity', + 'beatmapPP', + 'gameplay', + 'resultScreen', + 'settings', + 'tourneyManager', + 'user', + 'lazerMultiSpectating' + ]); while (!this.isDestroyed) { try { @@ -167,6 +175,10 @@ export class LazerInstance extends AbstractInstance { case GameState.selectMulti: case GameState.lobby: + if (global.isMultiSpectating) { + lazerMultiSpectating.updateState(); + } + break; default: diff --git a/packages/tosu/src/instances/manager.ts b/packages/tosu/src/instances/manager.ts index 9b71bf8e..3f212c76 100644 --- a/packages/tosu/src/instances/manager.ts +++ b/packages/tosu/src/instances/manager.ts @@ -89,10 +89,7 @@ export class InstanceManager { process.platform !== 'linux') || lazerOnLinux; - const osuInstance = isLazer - ? new LazerInstance(processId) - : new OsuInstance(processId); - const cmdLine = osuInstance.process.getProcessCommandLine(); + const cmdLine = Process.getProcessCommandLine(processId); const args = argumentsParser(cmdLine); if (args.tournament !== null && args.tournament !== undefined) { @@ -100,6 +97,15 @@ export class InstanceManager { continue; } + if (args['debug-client-id']) { + // skip lazer debug clients + continue; + } + + const osuInstance = isLazer + ? new LazerInstance(processId) + : new OsuInstance(processId); + if (!isNaN(parseFloat(args.spectateclient))) { osuInstance.setTourneyIpcId(args.spectateclient); osuInstance.setIsTourneySpectator(true); diff --git a/packages/tosu/src/memory/lazer.ts b/packages/tosu/src/memory/lazer.ts index 3c645e11..87ce249b 100644 --- a/packages/tosu/src/memory/lazer.ts +++ b/packages/tosu/src/memory/lazer.ts @@ -19,6 +19,8 @@ import type { IGlobalPrecise, IHitErrors, IKeyOverlay, + ILazerSpectator, + ILazerSpectatorEntry, ILeaderboard, IMP3Length, IMenu, @@ -34,6 +36,10 @@ import type { import type { ITourneyManagerChatItem } from '@/states/tourney'; import { LeaderboardPlayer, Statistics } from '@/states/types'; import { netDateBinaryToDate, numberFromDecimal } from '@/utils/converters'; +import { + MultiplayerTeamType, + MultiplayerUserState +} from '@/utils/multiplayer.types'; import { calculateMods, defaultCalculatedMods } from '@/utils/osuMods'; import { CalculateMods, @@ -44,7 +50,7 @@ import { import type { BindingsList, ConfigList } from '@/utils/settings.types'; type LazerPatternData = { - spectatorClient: number; + sessionIdleTracker: number; }; interface KeyCounter { @@ -54,10 +60,9 @@ interface KeyCounter { export class LazerMemory extends AbstractMemory { private scanPatterns: ScanPatterns = { - spectatorClient: { - pattern: - '3F 00 00 80 3F 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 80 3F 00 00 80 3F 00 00 80 3F 00 00 80 3F 00 00 80 3F 00 00 80 3F 00 00 80 3F 00 00 80 3F 00 00 80 3F 00 00 80 3F 00 00 80 3F 00 00 80 3F 00 00 80 3F 00 00 80 3F 00 00 80 3F 00 00 80 3F 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 ?? 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00', - offset: -0x16f + sessionIdleTracker: { + pattern: '00 00 00 00 80 4F 12 41', // aka 300000 in double + offset: -0x208 } }; @@ -76,7 +81,7 @@ export class LazerMemory extends AbstractMemory { private gameBaseAddress: number; patterns: LazerPatternData = { - spectatorClient: 0 + sessionIdleTracker: 0 }; private lazerToStableStatus = { @@ -94,9 +99,25 @@ export class LazerMemory extends AbstractMemory { private updateGameBaseAddress() { const oldAddress = this.gameBaseAddress; - const spectatorClient = this.getPattern('spectatorClient'); + const sessionIdleTracker = this.getPattern('sessionIdleTracker'); + + // this is why we like lazer more than stable (we can get everything from one place) this.gameBaseAddress = this.process.readIntPtr( - this.process.readIntPtr(spectatorClient + 0x90) + 0x90 + this.process.readIntPtr( + this.process.readIntPtr( + this.process.readIntPtr( + this.process.readIntPtr( + this.process.readIntPtr( + this.process.readIntPtr( + this.process.readIntPtr( + sessionIdleTracker + 0x90 + ) + 0x90 + ) + 0x90 + ) + 0x90 + ) + 0x90 + ) + 0x90 + ) + 0x90 + ) + 0x340 ); wLogger.debug( @@ -131,9 +152,9 @@ export class LazerMemory extends AbstractMemory { if (!this.checkIfGameBase(this.gameBaseAddress)) { wLogger.debug('lazer', this.pid, 'GameBase has been reset'); - const scanPattern = this.scanPatterns.spectatorClient; + const scanPattern = this.scanPatterns.sessionIdleTracker; this.setPattern( - 'spectatorClient', + 'sessionIdleTracker', this.process.scanSync(scanPattern.pattern) + scanPattern.offset! ); @@ -160,11 +181,11 @@ export class LazerMemory extends AbstractMemory { return this.process.readIntPtr(address + 0x400) === this.gameBase(); } - // Checks k__BackingField and k__BackingField (to GameBase::k__BackingField) + // Checks k__BackingField and k__BackingField (to GameBase::k__BackingField) private checkIfResultScreen(address: number) { return ( this.process.readIntPtr(address + 0x408) === - this.process.readIntPtr(this.gameBase() + 0x438) && + this.process.readIntPtr(this.gameBase() + 0x490) && this.process.readIntPtr(address + 0x348) !== this.process.readIntPtr(this.gameBase() + 0x450) ); @@ -194,15 +215,64 @@ export class LazerMemory extends AbstractMemory { } // Checks k__BackingField and k__BackingField - private checkIfMulti(address: number) { + private checkIfMultiSelect(address: number) { + const multiplayerClient = this.multiplayerClient(); + const isConnectedBindable = this.process.readIntPtr( + multiplayerClient + 0x2d8 + ); + + const isConnected = + this.process.readByte(isConnectedBindable + 0x40) === 1; + + if (!isConnected) { + return false; + } + + const currentRoom = this.process.readIntPtr(multiplayerClient + 0x288); + return ( + !currentRoom && this.process.readIntPtr(address + 0x3c0) === this.process.readIntPtr(this.gameBase() + 0x438) && this.process.readIntPtr(address + 0x3d0) === - this.process.readIntPtr(this.gameBase() + 0x4a8) + this.process.readIntPtr(this.gameBase() + 0x4b0) ); } + private checkIfMulti() { + const multiplayerClient = this.multiplayerClient(); + const isConnectedBindable = this.process.readIntPtr( + multiplayerClient + 0x2d8 + ); + + const isConnected = + this.process.readByte(isConnectedBindable + 0x40) === 1; + + if (!isConnected) { + return false; + } + + const currentRoom = this.process.readIntPtr(multiplayerClient + 0x288); + + return currentRoom; + } + + // k__BackingField / k__BackingField / k__BackingField + private checkIfMultiSpectator(address: number) { + return ( + this.process.readIntPtr(address + 0x380) === + this.process.readIntPtr(this.gameBase() + 0x638) && + this.process.readIntPtr(address + 0x3b0) === + this.process.readIntPtr(this.gameBase() + 0x4a8) && + this.process.readIntPtr(address + 0x400) === + this.process.readIntPtr(this.gameBase() + 0x4b0) + ); + } + + private multiplayerClient() { + return this.process.readIntPtr(this.gameBase() + 0x4b0); + } + private getCurrentScreen() { const screenStack = this.screenStack(); @@ -688,7 +758,8 @@ export class LazerMemory extends AbstractMemory { private readScore( scoreInfo: number, health: number = 0, - retries: number = 0 + retries: number = 0, + combo?: number ): IGameplay { const statistics = this.readStatistics(scoreInfo); @@ -704,10 +775,8 @@ export class LazerMemory extends AbstractMemory { if (username === 'osu!salad') username = 'salad!'; if (username === 'osu!topus') username = 'osu!topus!'; - let combo = 0; - const player = this.player(); - if (player) { + if (!combo && player) { const scoreProcessor = this.process.readIntPtr(player + 0x448); const comboBindable = this.process.readIntPtr( @@ -717,6 +786,10 @@ export class LazerMemory extends AbstractMemory { combo = this.process.readInt(comboBindable + 0x40); } + if (!combo) { + combo = 0; + } + let score = this.process.readLong(scoreInfo + 0x98); const config = this.osuConfig(); @@ -759,14 +832,10 @@ export class LazerMemory extends AbstractMemory { return []; } - user(): IUser { - const api = this.process.readIntPtr(this.gameBase() + 0x438); - const userBindable = this.process.readIntPtr(api + 0x250); - const user = this.process.readIntPtr(userBindable + 0x20); - - const statistics = this.process.readIntPtr(user + 0xa0); + readUser(user: number) { + const userId = this.process.readInt(user + 0xe8); - if (statistics === 0) { + if (userId === 0) { return { id: 0, name: 'Guest', @@ -784,14 +853,31 @@ export class LazerMemory extends AbstractMemory { }; } - const ppDecimal = statistics + 0x68 + 0x8; + const statistics = this.process.readIntPtr(user + 0xa0); - // TODO: read ulong instead long - const pp = numberFromDecimal( - this.process.readLong(ppDecimal + 0x8), - this.process.readUInt(ppDecimal + 0x4), - this.process.readInt(ppDecimal) - ); + let pp = 0; + let accuracy = 0; + let rankedScore = 0; + let level = 0; + let playCount = 0; + let rank = 0; + + if (statistics) { + const ppDecimal = statistics + 0x68 + 0x8; + + // TODO: read ulong instead long + pp = numberFromDecimal( + this.process.readLong(ppDecimal + 0x8), + this.process.readUInt(ppDecimal + 0x4), + this.process.readInt(ppDecimal) + ); + + accuracy = this.process.readDouble(statistics + 0x28); + rankedScore = this.process.readLong(statistics + 0x20); + level = this.process.readInt(statistics + 0x4c); + playCount = this.process.readInt(statistics + 0x38); + rank = this.process.readInt(statistics + 0x54 + 0x4); + } let gamemode = Rulesets[this.process.readSharpStringPtr(user + 0x88)]; @@ -800,14 +886,14 @@ export class LazerMemory extends AbstractMemory { } return { - id: this.process.readInt(user + 0xe8), + id: userId, name: this.process.readSharpStringPtr(user + 0x8), - accuracy: this.process.readDouble(statistics + 0x28), - rankedScore: this.process.readLong(statistics + 0x20), - level: this.process.readInt(statistics + 0x4c), - playCount: this.process.readInt(statistics + 0x38), + accuracy, + rankedScore, + level, + playCount, playMode: gamemode, - rank: this.process.readInt(statistics + 0x54 + 0x4), + rank, countryCode: CountryCodes[ this.process.readSharpStringPtr(user + 0x20).toLowerCase() @@ -819,6 +905,14 @@ export class LazerMemory extends AbstractMemory { }; } + user(): IUser { + const api = this.process.readIntPtr(this.gameBase() + 0x438); + const userBindable = this.process.readIntPtr(api + 0x250); + const user = this.process.readIntPtr(userBindable + 0x20); + + return this.readUser(user); + } + settingsPointers(): ISettingsPointers { throw new Error('Lazer:settingsPointers not implemented.'); } @@ -843,13 +937,11 @@ export class LazerMemory extends AbstractMemory { throw new Error('Lazer:bindingValue not implemented.'); } - resultScreen(): IResultScreen { - const selectedScoreBindable = this.process.readIntPtr( - this.currentScreen + 0x398 - ); - - const scoreInfo = this.process.readIntPtr(selectedScoreBindable + 0x20); - + buildResultScreen( + scoreInfo: number, + onlineId: number = -1, + date: string = new Date().toISOString() + ): IResultScreen { const score = this.readScore(scoreInfo); if (score instanceof Error) throw score; @@ -857,18 +949,6 @@ export class LazerMemory extends AbstractMemory { return 'not-ready'; } - const onlineId = Math.max( - this.process.readLong(this.currentScreen + 0xb0), - this.process.readLong(this.currentScreen + 0xb8) - ); - - const scoreDate = scoreInfo + 0x100; - - const date = netDateBinaryToDate( - this.process.readInt(scoreDate + 0x4), - this.process.readInt(scoreDate) - ).toISOString(); - return { onlineId, playerName: score.playerName, @@ -889,6 +969,30 @@ export class LazerMemory extends AbstractMemory { }; } + resultScreen(): IResultScreen { + const selectedScoreBindable = this.process.readIntPtr( + this.currentScreen + 0x398 + ); + + const scoreInfo = this.process.readIntPtr(selectedScoreBindable + 0x20); + + const onlineId = Math.max( + this.process.readLong(this.currentScreen + 0xb0), + this.process.readLong(this.currentScreen + 0xb8) + ); + + const scoreDate = scoreInfo + 0x100; + + return this.buildResultScreen( + scoreInfo, + onlineId, + netDateBinaryToDate( + this.process.readInt(scoreDate + 0x4), + this.process.readInt(scoreDate) + ).toISOString() + ); + } + gameplay(): IGameplay { if (this.isPlayerLoading) { return 'not-ready'; @@ -1726,7 +1830,10 @@ export class LazerMemory extends AbstractMemory { const isSongSelect = this.checkIfSongSelect(this.currentScreen); const isPlayerLoader = this.checkIfPlayerLoader(this.currentScreen); const isEditor = this.checkIfEditor(this.currentScreen); - const isMulti = this.checkIfMulti(this.currentScreen); + const isMultiSelect = this.checkIfMultiSelect(this.currentScreen); + const isMulti = this.checkIfMulti(); + + let isMultiSpectating = false; let status = 0; @@ -1738,19 +1845,21 @@ export class LazerMemory extends AbstractMemory { status = GameState.resultScreen; } else if (isEditor) { status = GameState.edit; + } else if (isMultiSelect) { + status = GameState.selectMulti; } else if (isMulti) { - const multiplayerClient = this.process.readIntPtr( - this.currentScreen + 0x3d0 - ); + const multiplayerClient = this.multiplayerClient(); const currentRoom = this.process.readIntPtr( - multiplayerClient + 0x298 + multiplayerClient + 0x288 ); if (currentRoom) { status = GameState.lobby; - } else { - status = GameState.selectMulti; + + isMultiSpectating = this.checkIfMultiSpectator( + this.currentScreen + ); } } @@ -1771,6 +1880,7 @@ export class LazerMemory extends AbstractMemory { isReplayUiHidden: false, showInterface: false, chatStatus: 0, + isMultiSpectating, status, gameTime: 0, menuMods: this.menuMods, @@ -1892,4 +2002,100 @@ export class LazerMemory extends AbstractMemory { return [true, personalScore, []]; } + + readSpectatingData(): ILazerSpectator { + const multiSpectatorScreen = this.currentScreen; + + const spectatingClients: ILazerSpectatorEntry[] = []; + + const gameplayStates = this.process.readIntPtr( + multiSpectatorScreen + 0x3e0 + ); + const gameplayStatesEntries = this.process.readIntPtr( + gameplayStates + 0x10 + ); + const gameplayStatesCount = this.process.readInt(gameplayStates + 0x38); + + const userStates: Record< + number, + { team: MultiplayerTeamType; state: MultiplayerUserState } + > = {}; + + const multiplayerClient = this.multiplayerClient(); + + const room = this.process.readIntPtr(multiplayerClient + 0x288); + + const multiplayerUsers = this.process.readIntPtr(room + 0x10); + const multiplayerUsersItems = this.process.readIntPtr( + multiplayerUsers + 0x8 + ); + const multiplayerUsersCount = this.process.readInt( + multiplayerUsers + 0x10 + ); + + for (let i = 0; i < multiplayerUsersCount; i++) { + const current = this.process.readIntPtr( + multiplayerUsersItems + 0x10 + 0x8 * i + ); + + const userId = this.process.readInt(current + 0x28); + const matchState = this.process.readIntPtr(current + 0x18); + + let team: MultiplayerTeamType = 'none'; + + if (matchState) { + const teamId = this.process.readInt(matchState + 0x8); + team = teamId === 0 ? 'red' : 'blue'; + } + + const state = this.process.readInt(current + 0x2c); + + userStates[userId] = { + team, + state + }; + } + + for (let i = 0; i < gameplayStatesCount; i++) { + const current = gameplayStatesEntries + 0x10 + 0x18 * i; + + const state = this.process.readIntPtr(current); + + const score = this.process.readIntPtr(state + 0x8); + const scoreInfo = this.process.readIntPtr(score + 0x8); + const gameplayScore = this.readScore(scoreInfo); + + const apiUser = this.process.readIntPtr(scoreInfo + 0x68); + const user = this.readUser(apiUser); + + if (gameplayScore instanceof Error) { + throw gameplayScore; + } + + if (typeof gameplayScore === 'string') { + return undefined; + } + + const userState = userStates[user.id]; + + spectatingClients.push({ + team: userState.team, + user, + score: gameplayScore, + resultScreen: + userState.state === MultiplayerUserState.Results + ? this.buildResultScreen(scoreInfo) + : undefined + }); + } + + // const multiplayerClient = this.multiplayerClient(); + + // const room = this.process.readIntPtr(multiplayerClient + 0x288); + + // const roomId = this.process.readInt(room + 0x38); + // const channelId = this.process.readInt(room + 0x44); + + return { chat: [], spectatingClients }; + } } diff --git a/packages/tosu/src/memory/stable.ts b/packages/tosu/src/memory/stable.ts index 5b1f6c97..495aa8e5 100644 --- a/packages/tosu/src/memory/stable.ts +++ b/packages/tosu/src/memory/stable.ts @@ -741,6 +741,9 @@ export class StableMemory extends AbstractMemory { isWatchingReplay, isReplayUiHidden, + // lazer logic + isMultiSpectating: false, + showInterface, chatStatus, status, diff --git a/packages/tosu/src/memory/types.ts b/packages/tosu/src/memory/types.ts index d31f877a..b380537e 100644 --- a/packages/tosu/src/memory/types.ts +++ b/packages/tosu/src/memory/types.ts @@ -1,5 +1,6 @@ import { ITourneyManagerChatItem } from '@/states/tourney'; import { KeyOverlay, LeaderboardPlayer } from '@/states/types'; +import { MultiplayerTeamType } from '@/utils/multiplayer.types'; import { CalculateMods } from '@/utils/osuMods.types'; export type ScanPatterns = { @@ -12,23 +13,23 @@ export type ScanPatterns = { export type IAudioVelocityBase = number[] | string; -export type IUser = - | Error - | { - name: string; - accuracy: number; - rankedScore: number; - id: number; - level: number; - playCount: number; - playMode: number; - rank: number; - countryCode: number; - performancePoints: number; - rawBanchoStatus: number; - backgroundColour: number; - rawLoginStatus: number; - }; +export interface IUserProtected { + name: string; + accuracy: number; + rankedScore: number; + id: number; + level: number; + playCount: number; + playMode: number; + rank: number; + countryCode: number; + performancePoints: number; + rawBanchoStatus: number; + backgroundColour: number; + rawLoginStatus: number; +} + +export type IUser = Error | IUserProtected; export type ISettingsPointers = { config: number; binding: number } | Error; export type IOffsets = number[] | Error; @@ -102,6 +103,7 @@ export type IGlobal = | { isWatchingReplay: boolean; isReplayUiHidden: boolean; + isMultiSpectating: boolean; showInterface: boolean; chatStatus: number; @@ -188,3 +190,17 @@ export type ITourneyUser = export type ILeaderboard = | [boolean, LeaderboardPlayer | undefined, LeaderboardPlayer[]] | Error; + +export interface ILazerSpectatorEntry { + team: MultiplayerTeamType; + user: IUser; + resultScreen: IResultScreen | undefined; + score: IScore | undefined; +} + +export type ILazerSpectator = + | { + chat: ITourneyManagerChatItem[]; + spectatingClients: ILazerSpectatorEntry[]; + } + | undefined; diff --git a/packages/tosu/src/states/beatmap.ts b/packages/tosu/src/states/beatmap.ts index 6f6cbebd..d8f4b4a4 100644 --- a/packages/tosu/src/states/beatmap.ts +++ b/packages/tosu/src/states/beatmap.ts @@ -301,14 +301,14 @@ export class BeatmapPP extends AbstractState { updateMapMetadata( currentMods: CalculateMods, currentMode: number, - lazerByPass: boolean = false + lazerBypass: boolean = false ) { try { const startTime = performance.now(); const { menu, global } = this.game.getServices(['menu', 'global']); - if (menu.folder === '.' && !lazerByPass) { + if (menu.folder === '.' && !lazerBypass) { wLogger.debug( ClientType[this.game.client], this.game.pid, @@ -344,6 +344,10 @@ export class BeatmapPP extends AbstractState { menu.filename ); + if (!menu.folder || !menu.filename) { + return 'not-ready'; + } + try { this.beatmapContent = fs.readFileSync(mapPath, 'utf8'); @@ -468,7 +472,7 @@ export class BeatmapPP extends AbstractState { if ( cleanPath(this.lazerBeatmap.events.backgroundPath || '') !== menu.backgroundFilename && - !lazerByPass + !lazerBypass ) { menu.backgroundFilename = cleanPath( this.lazerBeatmap.events.backgroundPath || '' diff --git a/packages/tosu/src/states/global.ts b/packages/tosu/src/states/global.ts index de4cef6e..dd9918c7 100644 --- a/packages/tosu/src/states/global.ts +++ b/packages/tosu/src/states/global.ts @@ -8,6 +8,7 @@ import { CalculateMods } from '@/utils/osuMods.types'; export class Global extends AbstractState { isWatchingReplay: boolean = false; isReplayUiHidden: boolean = false; + isMultiSpectating: boolean = false; showInterface: boolean = false; chatStatus: number = 0; @@ -53,6 +54,7 @@ export class Global extends AbstractState { this.isWatchingReplay = result.isWatchingReplay; this.isReplayUiHidden = result.isReplayUiHidden; + this.isMultiSpectating = result.isMultiSpectating; this.showInterface = result.showInterface; this.chatStatus = result.chatStatus; diff --git a/packages/tosu/src/states/lazerMultiSpectating.ts b/packages/tosu/src/states/lazerMultiSpectating.ts new file mode 100644 index 00000000..c37e8fed --- /dev/null +++ b/packages/tosu/src/states/lazerMultiSpectating.ts @@ -0,0 +1,40 @@ +import { ClientType, wLogger } from '@tosu/common'; + +import { type LazerInstance } from '@/instances/lazerInstance'; +import { ILazerSpectator } from '@/memory/types'; +import { AbstractState } from '@/states'; + +export class LazerMultiSpectating extends AbstractState { + lazerSpectatingData: ILazerSpectator; + + updateState() { + try { + if (this.game.client !== ClientType.lazer) { + throw new Error( + 'lazer multi spectating is not available for stable' + ); + } + + this.lazerSpectatingData = ( + this.game as LazerInstance + ).memory.readSpectatingData(); + + this.resetReportCount('lazerMultiSpectating updateState'); + } catch (exc) { + this.reportError( + 'lazerMultiSpectating updateState', + 10, + ClientType[this.game.client], + this.game.pid, + `lazerMultiSpectating updateState`, + (exc as any).message + ); + wLogger.debug( + ClientType[this.game.client], + this.game.pid, + `lazerMultiSpectating updateState`, + exc + ); + } + } +} diff --git a/packages/tosu/src/utils/multiplayer.types.ts b/packages/tosu/src/utils/multiplayer.types.ts new file mode 100644 index 00000000..68339998 --- /dev/null +++ b/packages/tosu/src/utils/multiplayer.types.ts @@ -0,0 +1,13 @@ +export enum MultiplayerUserState { + Idle, + Ready, + WaitingForLoad, + Loaded, + ReadyForGameplay, + Playing, + FinishedPlay, + Results, + Spectating +} + +export type MultiplayerTeamType = 'red' | 'blue' | 'none'; diff --git a/packages/tsprocess/lib/functions.cc b/packages/tsprocess/lib/functions.cc index eca9d64b..7a2053d0 100644 --- a/packages/tsprocess/lib/functions.cc +++ b/packages/tsprocess/lib/functions.cc @@ -301,8 +301,8 @@ Napi::Value find_processes(const Napi::CallbackInfo &args) { for (size_t i = 0; i < process_array.Length(); i++) { Napi::Value value = process_array[i]; if (value.IsString()) { - std::string name = value.As().Utf8Value(); - process_names.push_back(name); + std::string name = value.As().Utf8Value(); + process_names.push_back(name); } } @@ -327,6 +327,20 @@ Napi::Value open_process(const Napi::CallbackInfo &args) { return Napi::Number::New(env, reinterpret_cast(memory::open_process(process_id))); } +Napi::Value close_handle(const Napi::CallbackInfo &args) { + Napi::Env env = args.Env(); + if (args.Length() < 1) { + Napi::TypeError::New(env, "Wrong number of arguments").ThrowAsJavaScriptException(); + return env.Null(); + } + + auto handle = reinterpret_cast(args[0].As().Int64Value()); + + memory::close_handle(handle); + + return env.Undefined(); +} + Napi::Value is_process_exist(const Napi::CallbackInfo &args) { Napi::Env env = args.Env(); if (args.Length() < 1) { @@ -508,6 +522,7 @@ Napi::Object init(Napi::Env env, Napi::Object exports) { exports["scan"] = Napi::Function::New(env, scan); exports["batchScan"] = Napi::Function::New(env, batch_scan); exports["openProcess"] = Napi::Function::New(env, open_process); + exports["closeHandle"] = Napi::Function::New(env, close_handle); exports["findProcesses"] = Napi::Function::New(env, find_processes); exports["isProcessExist"] = Napi::Function::New(env, is_process_exist); exports["isProcess64bit"] = Napi::Function::New(env, is_process_64bit); diff --git a/packages/tsprocess/lib/memory/memory.h b/packages/tsprocess/lib/memory/memory.h index dfcccb15..573150b7 100644 --- a/packages/tsprocess/lib/memory/memory.h +++ b/packages/tsprocess/lib/memory/memory.h @@ -29,9 +29,10 @@ namespace memory { std::vector query_regions(void *process); -std::vector find_processes(const std::vector& process_names); +std::vector find_processes(const std::vector &process_names); void *open_process(uint32_t id); +void close_handle(void *handle); bool is_process_64bit(uint32_t id); bool is_process_exist(void *process); std::string get_process_path(void *process); diff --git a/packages/tsprocess/lib/memory/memory_linux.cc b/packages/tsprocess/lib/memory/memory_linux.cc index 7a40a14e..6c4606f1 100644 --- a/packages/tsprocess/lib/memory/memory_linux.cc +++ b/packages/tsprocess/lib/memory/memory_linux.cc @@ -23,7 +23,7 @@ std::string read_file(const std::string &path) { } // namespace -std::vector memory::find_processes(const std::vector& process_names) { +std::vector memory::find_processes(const std::vector &process_names) { std::vector process_ids; const auto dir = opendir("/proc"); if (dir) { @@ -35,9 +35,9 @@ std::vector memory::find_processes(const std::vector& pro const auto pid = std::stoi(pid_str); const auto cmdline_path = "/proc/" + pid_str + "/comm"; const auto cmdline = read_file(cmdline_path); - + // Check if the process name matches any in the provided list - for (const auto& process_name : process_names) { + for (const auto &process_name : process_names) { if (cmdline.find(process_name) != std::string::npos) { process_ids.push_back(pid); } @@ -54,6 +54,10 @@ void *memory::open_process(uint32_t id) { return reinterpret_cast(id); } +void memory::close_handle(void *handle) { + // do nothing +} + bool memory::is_process_exist(void *process) { const auto pid = reinterpret_cast(process); struct stat sts; diff --git a/packages/tsprocess/lib/memory/memory_windows.cc b/packages/tsprocess/lib/memory/memory_windows.cc index 98f65cc4..ff99a97e 100644 --- a/packages/tsprocess/lib/memory/memory_windows.cc +++ b/packages/tsprocess/lib/memory/memory_windows.cc @@ -35,7 +35,7 @@ std::vector memory::query_regions(void *process) { return regions; } -std::vector memory::find_processes(const std::vector& process_names) { +std::vector memory::find_processes(const std::vector &process_names) { PROCESSENTRY32 processEntry; processEntry.dwSize = sizeof(PROCESSENTRY32); @@ -44,7 +44,7 @@ std::vector memory::find_processes(const std::vector& pro HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, NULL); if (Process32First(snapshot, &processEntry)) { do { - for (const auto& process_name : process_names) { + for (const auto &process_name : process_names) { if (process_name == processEntry.szExeFile) { processes.push_back(processEntry.th32ProcessID); } @@ -61,6 +61,10 @@ void *memory::open_process(uint32_t id) { return OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, FALSE, id); } +void memory::close_handle(void *handle) { + CloseHandle(handle); +} + bool memory::is_process_exist(void *handle) { DWORD returnCode{}; if (GetExitCodeProcess(handle, &returnCode)) { diff --git a/packages/tsprocess/src/process.ts b/packages/tsprocess/src/process.ts index ed166bcc..c5c21b53 100644 --- a/packages/tsprocess/src/process.ts +++ b/packages/tsprocess/src/process.ts @@ -51,6 +51,24 @@ export class Process { return ProcessUtils.getForegroundWindowProcess(); } + static openProcess(id: number) { + return ProcessUtils.openProcess(id); + } + + static getProcessCommandLine(id: number) { + const handle = this.openProcess(id); + + const commandLine = ProcessUtils.getProcessCommandLine(handle); + + this.closeHandle(handle); + + return commandLine; + } + + static closeHandle(handle: number): void { + return ProcessUtils.closeHandle(handle); + } + get path(): string { if (process.platform === 'win32') { return pathDirname(ProcessUtils.getProcessPath(this.handle));