From bea706cfaa143b2b5d6ce803c8eecbf224164010 Mon Sep 17 00:00:00 2001 From: ck <21735205+cyperdark@users.noreply.github.com> Date: Thu, 18 Sep 2025 03:47:51 +0300 Subject: [PATCH 1/5] fix: Fix loading state between play and song select + add spectating and watching replay state --- packages/common/enums/osu.ts | 4 +- packages/tosu/src/instances/lazerInstance.ts | 2 + packages/tosu/src/memory/lazer.ts | 118 +++++++++++++++---- 3 files changed, 100 insertions(+), 24 deletions(-) diff --git a/packages/common/enums/osu.ts b/packages/common/enums/osu.ts index 5580aefd..9d8db377 100644 --- a/packages/common/enums/osu.ts +++ b/packages/common/enums/osu.ts @@ -34,7 +34,9 @@ export enum GameState { packageUpdater, benchmark, tourney, - charts + charts, + spectating, + watchingReplay } export enum LobbyStatus { diff --git a/packages/tosu/src/instances/lazerInstance.ts b/packages/tosu/src/instances/lazerInstance.ts index 4e52a0d9..6680ac72 100644 --- a/packages/tosu/src/instances/lazerInstance.ts +++ b/packages/tosu/src/instances/lazerInstance.ts @@ -314,6 +314,8 @@ export class LazerInstance extends AbstractInstance { break; case GameState.play: // is playing (after player is loaded) + case GameState.spectating: // is playing (after player is loaded) + case GameState.watchingReplay: // is playing (after player is loaded) // Reset gameplay data on retry if (this.previousTime > global.playTime) { gameplay.init(true); diff --git a/packages/tosu/src/memory/lazer.ts b/packages/tosu/src/memory/lazer.ts index f457e2cb..006ce585 100644 --- a/packages/tosu/src/memory/lazer.ts +++ b/packages/tosu/src/memory/lazer.ts @@ -88,6 +88,8 @@ export interface Offsets { 'k__BackingField': number; 'k__BackingField': number; 'k__BackingField': number; + 'k__BackingField': number; + 'k__BackingField': number; beatmapClock: number; 'k__BackingField': number; 'k__BackingField': number; @@ -106,6 +108,11 @@ export interface Offsets { }; 'osu.Game.Screens.Play.PlayerLoader': { osuLogo: number; + 'k__BackingField': number; + }; + 'osu.Game.Screens.Play.SpectatorPlayer': { + score: number; + 'k__BackingField': number; }; 'osu.Game.Beatmaps.FramedBeatmapClock': { finalClockSource: number; @@ -160,6 +167,7 @@ export interface Offsets { 'k__BackingField': number; 'k__BackingField': number; 'k__BackingField': number; + 'k__BackingField': number; dependencies: number; }; 'osu.Framework.Screens.ScreenStack': { @@ -398,8 +406,6 @@ export class LazerMemory extends AbstractMemory { private HUDVisibilityMode: number = 0; private ReplaySettingsOverlay: boolean = true; - private replayMode: boolean = false; - private modMappings: Map = new Map(); private isPlayerLoading: boolean = false; @@ -645,9 +651,21 @@ export class LazerMemory extends AbstractMemory { 'k__BackingField' ] ) === + this.process.readIntPtr( + this.gameBase() + this.offsets['osu.Game.OsuGame'].osuLogo + ) && this.process.readIntPtr( - address + this.offsets['osu.Game.OsuGame'].osuLogo - ) + address + + this.offsets['osu.Game.Screens.Play.PlayerLoader'][ + 'k__BackingField' + ] + ) === + this.process.readIntPtr( + this.gameBase() + + this.offsets['osu.Game.OsuGameBase'][ + 'k__BackingField' + ] + ) ); } @@ -674,6 +692,69 @@ export class LazerMemory extends AbstractMemory { ); } + private checkIfSpectator(address: number) { + return ( + this.process.readIntPtr(address + 0x3f0) === + this.process.readIntPtr( + this.gameBase() + + this.offsets['osu.Game.OsuGameBase'][ + 'k__BackingField' + ] + ) && + this.process.readIntPtr( + address + + this.offsets['osu.Game.Screens.Play.SpectatorPlayer'][ + 'k__BackingField' + ] + ) === + this.process.readIntPtr( + this.gameBase() + + this.offsets['osu.Game.OsuGameBase'][ + 'k__BackingField' + ] + ) && + this.process.readIntPtr( + address + + this.offsets['osu.Game.Screens.Play.SpectatorPlayer'].score + ) !== + this.process.readIntPtr( + this.gameBase() + + this.offsets['osu.Game.OsuGameBase'][ + 'k__BackingField' + ] + ) + ); + } + + private checkIfWatchingReplay(address: number) { + const drawableRuleset = this.process.readIntPtr( + this.player() + + this.offsets['osu.Game.Screens.Play.Player'][ + 'k__BackingField' + ] + ); + return ( + this.process.readIntPtr( + drawableRuleset + + this.offsets['osu.Game.Rulesets.UI.DrawableRuleset'][ + 'k__BackingField' + ] + ) !== 0 && + this.process.readIntPtr( + address + + this.offsets['osu.Game.Screens.Play.SpectatorPlayer'][ + 'k__BackingField' + ] + ) !== + this.process.readIntPtr( + this.gameBase() + + this.offsets['osu.Game.OsuGameBase'][ + 'k__BackingField' + ] + ) + ); + } + private checkIfMultiSelect(address: number) { const multiplayerClient = this.multiplayerClient(); const isConnectedBindable = this.process.readIntPtr( @@ -2870,18 +2951,27 @@ export class LazerMemory extends AbstractMemory { const filesFolder = path.join(this.basePath(), 'files'); const isPlaying = this.player() !== 0; + const isResultScreen = this.checkIfResultScreen(this.currentScreen); const isSongSelectV2 = this.checkIfSongSelectV2(this.currentScreen); const isPlayerLoader = this.checkIfPlayerLoader(this.currentScreen); const isEditor = this.checkIfEditor(this.currentScreen); + const isSpectator = this.checkIfSpectator(this.currentScreen); const isMultiSelect = this.checkIfMultiSelect(this.currentScreen); const isMulti = this.checkIfMulti(); + const isReplay = isPlaying + ? this.checkIfWatchingReplay(this.currentScreen) + : false; let isMultiSpectating = false; let status = 0; - if (isPlaying || isPlayerLoader) { + if (isReplay && (isPlaying || isPlayerLoader)) { + status = GameState.watchingReplay; + } else if (isSpectator && (isPlaying || isPlayerLoader)) { + status = GameState.spectating; + } else if (isPlaying || isPlayerLoader) { status = GameState.play; } else if (isSongSelectV2) { status = GameState.selectPlay; @@ -2912,24 +3002,6 @@ export class LazerMemory extends AbstractMemory { this.isPlayerLoading = isPlayerLoader; - if (isPlaying) { - const dependencies = this.process.readIntPtr( - this.player() + - this.offsets['osu.Game.Screens.Play.Player'].dependencies - ); - const cache = this.process.readIntPtr(dependencies + 0x8); - const entries = this.process.readIntPtr(cache + 0x10); - const drawableRuleset = this.process.readIntPtr(entries + 0x10); - - this.replayMode = - this.process.readIntPtr( - drawableRuleset + - this.offsets['osu.Game.Rulesets.UI.DrawableRuleset'][ - 'k__BackingField' - ] - ) !== 0; - } - return { isWatchingReplay: this.replayMode, isReplayUiHidden: !this.ReplaySettingsOverlay, From 078ceffa52ce5c7d6e164f5690bef522180c2516 Mon Sep 17 00:00:00 2001 From: ck <21735205+cyperdark@users.noreply.github.com> Date: Thu, 18 Sep 2025 03:50:59 +0300 Subject: [PATCH 2/5] feat: New fields `Focused`, `Paused` and `Failed` --- packages/tosu/src/api/types/v2.ts | 8 ++++++++ packages/tosu/src/api/utils/buildResultV2.ts | 9 ++++++++- packages/tosu/src/instances/manager.ts | 2 ++ packages/tosu/src/memory/lazer.ts | 6 ++++-- packages/tosu/src/memory/stable.ts | 2 ++ packages/tosu/src/memory/types.ts | 1 + packages/tosu/src/states/gameplay.ts | 6 ++++++ packages/tosu/src/states/global.ts | 2 ++ 8 files changed, 33 insertions(+), 3 deletions(-) diff --git a/packages/tosu/src/api/types/v2.ts b/packages/tosu/src/api/types/v2.ts index b4e8e5a7..173d4035 100644 --- a/packages/tosu/src/api/types/v2.ts +++ b/packages/tosu/src/api/types/v2.ts @@ -4,6 +4,7 @@ export type ApiAnswer = TosuAPi | { error?: string }; export type ApiAnswerPrecise = TosuPreciseAnswer | { error?: string }; export interface TosuAPi { + game: Game; client: string; server: string; state: NumberName; @@ -21,6 +22,11 @@ export interface TosuAPi { tourney: Tourney | undefined; } +export interface Game { + focused: boolean; + paused: boolean; +} + export interface BeatmapTime { live: number; firstObject: number; @@ -281,6 +287,8 @@ export interface Objects { } export interface Play { + failed: boolean; + playerName: string; mode: NumberName; score: number; diff --git a/packages/tosu/src/api/utils/buildResultV2.ts b/packages/tosu/src/api/utils/buildResultV2.ts index a1c3f371..6a9f4444 100644 --- a/packages/tosu/src/api/utils/buildResultV2.ts +++ b/packages/tosu/src/api/utils/buildResultV2.ts @@ -21,6 +21,7 @@ import path from 'path'; import { ApiAnswer, Leaderboard, + Play, Tourney, TourneyChatMessages, TourneyClients @@ -125,6 +126,10 @@ export const buildResult = (instanceManager: InstanceManager): ApiAnswer => { : menu.gamemode; return { + game: { + focused: instanceManager.gameFocused, + paused: global.paused + }, client: ClientType[osuInstance.client], server: osuInstance.customServerEndpoint ?? 'ppy.sh', state: { @@ -573,6 +578,7 @@ const buildLazerTourneyData = ( }, play: { + failed: false, playerName: client.score!.playerName, mode: { @@ -884,8 +890,9 @@ function buildPlay( gameplay: Gameplay, beatmapPP: BeatmapPP, currentMods: CalculateMods -) { +): Play { return { + failed: gameplay.failed, playerName: gameplay.playerName, mode: { diff --git a/packages/tosu/src/instances/manager.ts b/packages/tosu/src/instances/manager.ts index d2be91c9..c4a784ad 100644 --- a/packages/tosu/src/instances/manager.ts +++ b/packages/tosu/src/instances/manager.ts @@ -21,6 +21,7 @@ import { OsuInstance } from './osuInstance'; export class InstanceManager { platformType: Platform; focusedClient: ClientType; + gameFocused: boolean; osuInstances: { [key: number]: AbstractInstance; @@ -179,6 +180,7 @@ export class InstanceManager { (r) => r.pid === focusedPID ); if (instance) this.focusedClient = instance.client; + this.gameFocused = instance !== undefined; if (this.focusedClient === undefined) { this.focusedClient = diff --git a/packages/tosu/src/memory/lazer.ts b/packages/tosu/src/memory/lazer.ts index 006ce585..c34b9f54 100644 --- a/packages/tosu/src/memory/lazer.ts +++ b/packages/tosu/src/memory/lazer.ts @@ -32,6 +32,7 @@ import type { IMP3Length, IMenu, IResultScreen, + IScore, ISettings, ITourney, ITourneyChat, @@ -1673,7 +1674,7 @@ export class LazerMemory extends AbstractMemory { health: number = 0, retries: number = 0, combo?: number - ): IGameplay { + ): IScore { const statistics = this.readStatistics(scoreInfo); const maximumStatistics = this.readMaximumStatistics(scoreInfo); @@ -1745,6 +1746,7 @@ export class LazerMemory extends AbstractMemory { } return { + failed: health <= 0, retries, playerName: username, mods, @@ -3003,7 +3005,7 @@ export class LazerMemory extends AbstractMemory { this.isPlayerLoading = isPlayerLoader; return { - isWatchingReplay: this.replayMode, + isWatchingReplay: status === GameState.watchingReplay, isReplayUiHidden: !this.ReplaySettingsOverlay, showInterface: this.HUDVisibilityMode > 0, chatStatus: 0, diff --git a/packages/tosu/src/memory/stable.ts b/packages/tosu/src/memory/stable.ts index a4ad05cb..522d1ebf 100644 --- a/packages/tosu/src/memory/stable.ts +++ b/packages/tosu/src/memory/stable.ts @@ -587,6 +587,7 @@ export class StableMemory extends AbstractMemory { this.gameplayMode = mode; return { + failed: playerHP <= 0, retries, playerName, mods, @@ -790,6 +791,7 @@ export class StableMemory extends AbstractMemory { const rulesetAddr = this.process.readInt( this.process.readInt(rulesetsAddr - 0xb) + 0x4 ); + if (rulesetAddr !== 0) { isReplayUiHidden = Boolean( this.process.readByte(rulesetAddr + 0x1d8) diff --git a/packages/tosu/src/memory/types.ts b/packages/tosu/src/memory/types.ts index a3919cc7..b32074fa 100644 --- a/packages/tosu/src/memory/types.ts +++ b/packages/tosu/src/memory/types.ts @@ -72,6 +72,7 @@ export type IResultScreen = | Error; export type IScore = { + failed: boolean; retries: number; playerName: string; mods: CalculateMods; diff --git a/packages/tosu/src/states/gameplay.ts b/packages/tosu/src/states/gameplay.ts index 1c73a3dc..cf4acdaa 100644 --- a/packages/tosu/src/states/gameplay.ts +++ b/packages/tosu/src/states/gameplay.ts @@ -52,6 +52,8 @@ export class Gameplay extends AbstractState { performanceAttributes: rosu.PerformanceAttributes | undefined; gradualPerformance: rosu.GradualPerformance | undefined; + failed: boolean; + retries: number; playerName: string; mods: CalculateMods = Object.assign({}, defaultCalculatedMods); @@ -99,6 +101,8 @@ export class Gameplay extends AbstractState { `gameplay init (${isRetry} - ${from})` ); + this.failed = false; + this.hitErrors = []; this.maxCombo = 0; this.score = 0; @@ -201,6 +205,8 @@ export class Gameplay extends AbstractState { // needed for ex like you done with replay watching/gameplay and return to mainMenu, you need alteast one reset to gameplay/resultScreen this.isDefaultState = false; + this.failed = result.failed; + this.retries = result.retries; this.playerName = result.playerName; this.mods = result.mods; diff --git a/packages/tosu/src/states/global.ts b/packages/tosu/src/states/global.ts index 6bb98462..e4fea646 100644 --- a/packages/tosu/src/states/global.ts +++ b/packages/tosu/src/states/global.ts @@ -14,6 +14,7 @@ export class Global extends AbstractState { chatStatus: number = 0; status: number = 0; + paused: boolean = false; gameTime: number = 0; playTime: number = 0; menuMods: CalculateMods = Object.assign({}, defaultCalculatedMods); @@ -91,6 +92,7 @@ export class Global extends AbstractState { const result = this.game.memory.globalPrecise(); if (result instanceof Error) throw result; + this.paused = result.time === this.playTime; this.playTime = result.time; this.game.resetReportCount('global updatePreciseState'); From 0ada2907036848bddc290b6a78b148ac62519836 Mon Sep 17 00:00:00 2001 From: ck <21735205+cyperdark@users.noreply.github.com> Date: Fri, 19 Sep 2025 04:15:05 +0300 Subject: [PATCH 3/5] chore: forgot to add states --- packages/tosu/src/instances/lazerInstance.ts | 2 ++ packages/tosu/src/instances/osuInstance.ts | 4 ++++ packages/tosu/src/memory/stable.ts | 12 ++++++++++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/tosu/src/instances/lazerInstance.ts b/packages/tosu/src/instances/lazerInstance.ts index 6680ac72..8832f818 100644 --- a/packages/tosu/src/instances/lazerInstance.ts +++ b/packages/tosu/src/instances/lazerInstance.ts @@ -384,6 +384,8 @@ export class LazerInstance extends AbstractInstance { switch (global.status) { case GameState.play: + case GameState.spectating: + case GameState.watchingReplay: if (global.playTime < 150) { break; } diff --git a/packages/tosu/src/instances/osuInstance.ts b/packages/tosu/src/instances/osuInstance.ts index 0995c8d7..49a7b9ca 100644 --- a/packages/tosu/src/instances/osuInstance.ts +++ b/packages/tosu/src/instances/osuInstance.ts @@ -150,6 +150,8 @@ export class OsuInstance extends AbstractInstance { break; case GameState.play: + case GameState.spectating: + case GameState.watchingReplay: // Reset gameplay data on retry if (this.previousTime > global.playTime) { gameplay.init(true); @@ -224,6 +226,8 @@ export class OsuInstance extends AbstractInstance { switch (global.status) { case GameState.play: + case GameState.spectating: + case GameState.watchingReplay: if (global.playTime < 150) { break; } diff --git a/packages/tosu/src/memory/stable.ts b/packages/tosu/src/memory/stable.ts index 522d1ebf..78c0cbc2 100644 --- a/packages/tosu/src/memory/stable.ts +++ b/packages/tosu/src/memory/stable.ts @@ -1,4 +1,10 @@ -import { ClientType, config, isAllowedValue, wLogger } from '@tosu/common'; +import { + ClientType, + GameState, + config, + isAllowedValue, + wLogger +} from '@tosu/common'; import { getContentType } from '@tosu/server'; import { AbstractMemory } from '@/memory'; @@ -759,7 +765,7 @@ export class StableMemory extends AbstractMemory { 'gameTimePtr' ]); - const status = this.process.readPointer(statusPtr); + let status = this.process.readPointer(statusPtr); const menuMods = this.process.readPointer(menuModsPtr); const chatStatus = this.process.readByte( this.process.readInt(chatCheckerPtr) @@ -797,6 +803,8 @@ export class StableMemory extends AbstractMemory { this.process.readByte(rulesetAddr + 0x1d8) ); } + + status = GameState.watchingReplay; } const skinOsuAddr = this.process.readInt(skinDataAddr + 0x7); From 22660276751416ab76279e57c396f6bcabf7919a Mon Sep 17 00:00:00 2001 From: ck <21735205+cyperdark@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:05:53 +0300 Subject: [PATCH 4/5] chore: temporary remove new states (breaking change) --- packages/common/enums/osu.ts | 6 +++--- packages/tosu/src/instances/lazerInstance.ts | 6 +----- packages/tosu/src/instances/osuInstance.ts | 4 ---- packages/tosu/src/memory/lazer.ts | 8 +++++--- packages/tosu/src/memory/stable.ts | 12 +++--------- 5 files changed, 12 insertions(+), 24 deletions(-) diff --git a/packages/common/enums/osu.ts b/packages/common/enums/osu.ts index 9d8db377..6c7e6deb 100644 --- a/packages/common/enums/osu.ts +++ b/packages/common/enums/osu.ts @@ -34,9 +34,9 @@ export enum GameState { packageUpdater, benchmark, tourney, - charts, - spectating, - watchingReplay + charts + // spectating, + // watchingReplay } export enum LobbyStatus { diff --git a/packages/tosu/src/instances/lazerInstance.ts b/packages/tosu/src/instances/lazerInstance.ts index 8832f818..333f0cfe 100644 --- a/packages/tosu/src/instances/lazerInstance.ts +++ b/packages/tosu/src/instances/lazerInstance.ts @@ -313,9 +313,7 @@ export class LazerInstance extends AbstractInstance { } break; - case GameState.play: // is playing (after player is loaded) - case GameState.spectating: // is playing (after player is loaded) - case GameState.watchingReplay: // is playing (after player is loaded) + case GameState.play: // Reset gameplay data on retry if (this.previousTime > global.playTime) { gameplay.init(true); @@ -384,8 +382,6 @@ export class LazerInstance extends AbstractInstance { switch (global.status) { case GameState.play: - case GameState.spectating: - case GameState.watchingReplay: if (global.playTime < 150) { break; } diff --git a/packages/tosu/src/instances/osuInstance.ts b/packages/tosu/src/instances/osuInstance.ts index 49a7b9ca..0995c8d7 100644 --- a/packages/tosu/src/instances/osuInstance.ts +++ b/packages/tosu/src/instances/osuInstance.ts @@ -150,8 +150,6 @@ export class OsuInstance extends AbstractInstance { break; case GameState.play: - case GameState.spectating: - case GameState.watchingReplay: // Reset gameplay data on retry if (this.previousTime > global.playTime) { gameplay.init(true); @@ -226,8 +224,6 @@ export class OsuInstance extends AbstractInstance { switch (global.status) { case GameState.play: - case GameState.spectating: - case GameState.watchingReplay: if (global.playTime < 150) { break; } diff --git a/packages/tosu/src/memory/lazer.ts b/packages/tosu/src/memory/lazer.ts index c34b9f54..e4927701 100644 --- a/packages/tosu/src/memory/lazer.ts +++ b/packages/tosu/src/memory/lazer.ts @@ -2966,13 +2966,15 @@ export class LazerMemory extends AbstractMemory { : false; let isMultiSpectating = false; + let watchingReplay = false; let status = 0; if (isReplay && (isPlaying || isPlayerLoader)) { - status = GameState.watchingReplay; + watchingReplay = true; + status = GameState.play; } else if (isSpectator && (isPlaying || isPlayerLoader)) { - status = GameState.spectating; + status = GameState.play; } else if (isPlaying || isPlayerLoader) { status = GameState.play; } else if (isSongSelectV2) { @@ -3005,7 +3007,7 @@ export class LazerMemory extends AbstractMemory { this.isPlayerLoading = isPlayerLoader; return { - isWatchingReplay: status === GameState.watchingReplay, + isWatchingReplay: watchingReplay, isReplayUiHidden: !this.ReplaySettingsOverlay, showInterface: this.HUDVisibilityMode > 0, chatStatus: 0, diff --git a/packages/tosu/src/memory/stable.ts b/packages/tosu/src/memory/stable.ts index 78c0cbc2..9c6d066e 100644 --- a/packages/tosu/src/memory/stable.ts +++ b/packages/tosu/src/memory/stable.ts @@ -1,10 +1,4 @@ -import { - ClientType, - GameState, - config, - isAllowedValue, - wLogger -} from '@tosu/common'; +import { ClientType, config, isAllowedValue, wLogger } from '@tosu/common'; import { getContentType } from '@tosu/server'; import { AbstractMemory } from '@/memory'; @@ -765,7 +759,7 @@ export class StableMemory extends AbstractMemory { 'gameTimePtr' ]); - let status = this.process.readPointer(statusPtr); + const status = this.process.readPointer(statusPtr); const menuMods = this.process.readPointer(menuModsPtr); const chatStatus = this.process.readByte( this.process.readInt(chatCheckerPtr) @@ -804,7 +798,7 @@ export class StableMemory extends AbstractMemory { ); } - status = GameState.watchingReplay; + // status = GameState.watchingReplay; } const skinOsuAddr = this.process.readInt(skinDataAddr + 0x7); From e75d9545d11750b65acd71a5d47b554d6014c353 Mon Sep 17 00:00:00 2001 From: ck <21735205+cyperdark@users.noreply.github.com> Date: Wed, 5 Nov 2025 14:25:07 +0300 Subject: [PATCH 5/5] chore: move from precise --- packages/tosu/src/states/global.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/tosu/src/states/global.ts b/packages/tosu/src/states/global.ts index e4fea646..84a0e622 100644 --- a/packages/tosu/src/states/global.ts +++ b/packages/tosu/src/states/global.ts @@ -17,6 +17,8 @@ export class Global extends AbstractState { paused: boolean = false; gameTime: number = 0; playTime: number = 0; + previousPlayTime: number = 0; + menuMods: CalculateMods = Object.assign({}, defaultCalculatedMods); gameFolder: string = ''; @@ -65,6 +67,9 @@ export class Global extends AbstractState { this.gameTime = result.gameTime; this.menuMods = result.menuMods; + this.paused = this.previousPlayTime === this.playTime; + this.previousPlayTime = this.playTime; + this.skinFolder = safeJoin(result.skinFolder); this.memorySongsFolder = safeJoin(result.memorySongsFolder); @@ -92,7 +97,6 @@ export class Global extends AbstractState { const result = this.game.memory.globalPrecise(); if (result instanceof Error) throw result; - this.paused = result.time === this.playTime; this.playTime = result.time; this.game.resetReportCount('global updatePreciseState');