diff --git a/.eslintrc.yml b/.eslintrc.yml index 8c4eb172..538a8bb3 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -21,6 +21,9 @@ settings: react: version: 'detect' rules: + no-empty: + - error + - allowEmptyCatch: true linebreak-style: - error - unix diff --git a/package.json b/package.json index dc427af8..95efdfd2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crewlink", - "version": "1.2.1", + "version": "2.0.1", "license": "GPL-3.0-or-later", "description": "Free, open, Among Us proximity voice chat", "repository": { @@ -47,11 +47,12 @@ "axios": "^0.21.0", "cross-spawn": "^7.0.3", "deep-equal": "^2.0.5", + "electron-overlay-window": "^1.0.4", "electron-store": "^6.0.1", "electron-updater": "^4.3.5", "electron-window-state": "^5.0.3", "iohook": "git://github.com/zbanks/iohook.git#0.7.2_zbanks", - "memoryjs": "https://github.com/zbanks/memoryjs.git", + "memoryjs": "https://github.com/TheGreatMcPain/memoryjs.git", "pretty-bytes": "^5.5.0", "react": "^17.0.1", "react-dom": "^17.0.1", @@ -80,6 +81,7 @@ "@types/webpack-env": "^1.15.3", "@typescript-eslint/eslint-plugin": "^4.9.1", "@typescript-eslint/parser": "^4.9.1", + "arraybuffer-loader": "^1.0.8", "electron": "9.3.3", "electron-builder": "^22.9.1", "electron-webpack": "^2.8.2", diff --git a/src/common/AmongUsState.ts b/src/common/AmongUsState.ts index 61ae9091..4fd3fc55 100644 --- a/src/common/AmongUsState.ts +++ b/src/common/AmongUsState.ts @@ -6,10 +6,13 @@ export interface AmongUsState { isHost: boolean; clientId: number; hostId: number; + commsSabotaged: boolean; } + export interface Player { ptr: number; id: number; + clientId: number; name: string; colorId: number; hatId: number; @@ -26,6 +29,14 @@ export interface Player { y: number; inVent: boolean; } + +export enum MapType { + THE_SKELD, + MIRA_HQ, + POLUS, + UNKNOWN, +} + export enum GameState { LOBBY, TASKS, @@ -33,3 +44,28 @@ export enum GameState { MENU, UNKNOWN, } + +export interface Client { + playerId: number; + clientId: number; +} +export interface SocketClientMap { + [socketId: string]: Client; +} +export interface OtherTalking { + [playerId: number]: boolean; // isTalking +} + +export interface AudioConnected { + [peer: string]: boolean; // isConnected +} + +export interface VoiceState { + otherTalking: OtherTalking; + playerSocketIds: { + [index: number]: string; + }; + otherDead: OtherTalking; + socketClients: SocketClientMap; + audioConnected: AudioConnected; +} diff --git a/src/common/ISettings.d.ts b/src/common/ISettings.d.ts index 07954cdc..71191c1a 100644 --- a/src/common/ISettings.d.ts +++ b/src/common/ISettings.d.ts @@ -9,9 +9,14 @@ export interface ISettings { muteShortcut: string; hideCode: boolean; enableSpatialAudio: boolean; + meetingOverlay: boolean; + overlayPosition: 'left' | 'right' | 'hidden'; localLobbySettings: ILobbySettings; } export interface ILobbySettings { maxDistance: number; + haunting: boolean; + hearImpostorsInVents: boolean; + commsSabotage: boolean; } diff --git a/src/common/ipc-messages.ts b/src/common/ipc-messages.ts index 9c152e19..2070237f 100644 --- a/src/common/ipc-messages.ts +++ b/src/common/ipc-messages.ts @@ -6,6 +6,14 @@ export enum IpcMessages { OPEN_AMONG_US_GAME = 'OPEN_AMONG_US_GAME', RESTART_CREWLINK = 'RESTART_CREWLINK', QUIT_CREWLINK = 'QUIT_CREWLINK', + SEND_TO_OVERLAY = 'SEND_TO_OVERLAY', +} + +// Renderer 1 --> Overlay Window (send/on) +export enum IpcOverlayMessages { + NOTIFY_GAME_STATE_CHANGED = 'NOTIFY_GAME_STATE_CHANGED', + NOTIFY_VOICE_STATE_CHANGED = 'NOTIFY_VOICE_STATE_CHANGED', + NOTIFY_SETTINGS_CHANGED = 'NOTIFY_SETTINGS_CHANGED', } // Renderer --> Main (sendSync/on) diff --git a/src/main/GameReader.ts b/src/main/GameReader.ts index 6a833cf7..3638a6ab 100644 --- a/src/main/GameReader.ts +++ b/src/main/GameReader.ts @@ -1,18 +1,23 @@ import { DataType, findModule, + getProcesses, ModuleObject, openProcess, ProcessObject, readBuffer, readMemory as readMemoryRaw, + findPattern as findPatternRaw, } from 'memoryjs'; import Struct from 'structron'; import { IpcRendererMessages } from '../common/ipc-messages'; -import { GameState, AmongUsState, Player } from '../common/AmongUsState'; +import { + GameState, + AmongUsState, + Player, + MapType, +} from '../common/AmongUsState'; import equal from 'deep-equal'; -import { createHash } from 'crypto'; -import { readFileSync } from 'fs'; import offsetStore, { IOffsets } from './offsetStore'; import Errors from '../common/Errors'; @@ -44,32 +49,26 @@ export default class GameReader { lastPlayerPtr = 0; shouldReadLobby = false; exileCausesEnd = false; + is64Bit = false; oldGameState = GameState.UNKNOWN; lastState: AmongUsState = {} as AmongUsState; - amongUs: ProcessObject | null = null; gameAssembly: ModuleObject | null = null; - dllHash: string | null = null; gameCode = 'MENU'; checkProcessOpen(): void { - // const processOpen = getProcesses().find( - // (p) => p.szExeFile === 'Among Us.exe' - // ); - const processOpen = true; + const processOpen = getProcesses().find( + (p) => p.szExeFile === 'Among Us.exe' + ); if (!this.amongUs && processOpen) { - // If process just opened try { this.amongUs = openProcess('Among Us.exe'); this.gameAssembly = findModule( 'GameAssembly.dll', this.amongUs.th32ProcessID ); - - const dllHash = createHash('sha256'); - dllHash.update(readFileSync(this.gameAssembly.szExePath)); - this.dllHash = dllHash.digest('base64'); + this.initializeoffsets(); this.sendIPC(IpcRendererMessages.NOTIFY_GAME_OPENED, true); } catch (e) { if (processOpen && e.toString() === 'Error: unable to find process') @@ -78,7 +77,6 @@ export default class GameReader { } } else if (this.amongUs && !processOpen) { this.amongUs = null; - this.dllHash = null; this.sendIPC(IpcRendererMessages.NOTIFY_GAME_OPENED, false); } return; @@ -90,55 +88,34 @@ export default class GameReader { } catch (e) { return e; } - if (!this.offsets && this.dllHash) { - if (!Object.prototype.hasOwnProperty.call(offsetStore, this.dllHash)) { - return Errors.UNSUPPORTED_VERSION; - } - this.offsets = offsetStore[this.dllHash]; - this.PlayerStruct = new Struct(); - for (const member of this.offsets.offsets.player.struct) { - if (member.type === 'SKIP' && member.skip) { - this.PlayerStruct = this.PlayerStruct.addMember( - Struct.TYPES.SKIP(member.skip), - member.name - ); - } else { - this.PlayerStruct = this.PlayerStruct.addMember( - Struct.TYPES[member.type] as ValueType, - member.name - ); - } - } - } if ( - this.amongUs !== null && - this.gameAssembly !== null && + this.PlayerStruct && this.offsets && - this.PlayerStruct + this.amongUs !== null && + this.gameAssembly !== null ) { - const offsets = this.offsets.offsets; let state = GameState.UNKNOWN; const meetingHud = this.readMemory( 'pointer', this.gameAssembly.modBaseAddr, - offsets.meetingHud + this.offsets.meetingHud ); const meetingHud_cachePtr = meetingHud === 0 ? 0 : this.readMemory( - 'uint32', + 'pointer', meetingHud, - offsets.meetingHudCachePtr + this.offsets.meetingHudCachePtr ); const meetingHudState = meetingHud_cachePtr === 0 ? 4 - : this.readMemory('int', meetingHud, offsets.meetingHudState, 4); + : this.readMemory('int', meetingHud, this.offsets.meetingHudState, 4); const gameState = this.readMemory( 'int', this.gameAssembly.modBaseAddr, - offsets.gameState + this.offsets.gameState ); switch (gameState) { @@ -158,63 +135,147 @@ export default class GameReader { break; } - const allPlayersPtr = - this.readMemory( - 'ptr', - this.gameAssembly.modBaseAddr, - offsets.allPlayersPtr - ) & 0xffffffff; + this.gameCode = + state === GameState.MENU + ? '' + : this.IntToGameCode( + this.readMemory( + 'int32', + this.gameAssembly.modBaseAddr, + this.offsets.gameCode + ) + ); + + const hostId = this.readMemory( + 'uint32', + this.gameAssembly.modBaseAddr, + this.offsets.hostId + ); + const clientId = this.readMemory( + 'uint32', + this.gameAssembly.modBaseAddr, + this.offsets.clientId + ); + + const allPlayersPtr = this.readMemory( + 'ptr', + this.gameAssembly.modBaseAddr, + this.offsets.allPlayersPtr + ); const allPlayers = this.readMemory( 'ptr', allPlayersPtr, - offsets.allPlayers + this.offsets.allPlayers ); const playerCount = this.readMemory( 'int' as const, allPlayersPtr, - offsets.playerCount + this.offsets.playerCount ); - let playerAddrPtr = allPlayers + offsets.playerAddrPtr; + let playerAddrPtr = allPlayers + this.offsets.playerAddrPtr; const players = []; const exiledPlayerId = this.readMemory( 'byte', this.gameAssembly.modBaseAddr, - offsets.exiledPlayerId + this.offsets.exiledPlayerId ); let impostors = 0, crewmates = 0; - for (let i = 0; i < Math.min(playerCount, 100); i++) { - const { address, last } = this.offsetAddress( - playerAddrPtr, - offsets.player.offsets + let commsSabotaged = false; + + if (this.gameCode) { + for (let i = 0; i < Math.min(playerCount, 100); i++) { + const { address, last } = this.offsetAddress( + playerAddrPtr, + this.offsets.player.offsets + ); + const playerData = readBuffer( + this.amongUs.handle, + address + last, + this.offsets.player.bufferLength + ); + + const player = this.parsePlayer(address + last, playerData, clientId); + playerAddrPtr += this.is64Bit ? 8 : 4; + if (!player) continue; + players.push(player); + + if ( + player.name === '' || + player.id === exiledPlayerId || + player.isDead || + player.disconnected + ) + continue; + + if (player.isImpostor) impostors++; + else crewmates++; + } + + const shipPtr = this.readMemory( + 'ptr', + this.gameAssembly.modBaseAddr, + this.offsets.shipStatus ); - const playerData = readBuffer( - this.amongUs.handle, - address + last, - offsets.player.bufferLength + + const systemsPtr = this.readMemory( + 'ptr', + shipPtr, + this.offsets.shipStatusSystems ); - const player = this.parsePlayer( - address + last, - playerData, - this.offsets, - this.PlayerStruct + const map: MapType = this.readMemory( + 'int32', + shipPtr, + this.offsets.shipStatusMap, + MapType.UNKNOWN ); - playerAddrPtr += 4; - if (state !== GameState.MENU) - players.push(player); if ( - player.name === '' || - player.id === exiledPlayerId || - player.isDead || - player.disconnected - ) - continue; - - if (player.isImpostor) impostors++; - else crewmates++; + systemsPtr !== 0 && + (state === GameState.TASKS || state === GameState.DISCUSSION) + ) { + const entries = this.readMemory( + 'ptr', + systemsPtr + (this.is64Bit ? 0x18 : 0xc) + ); + const len = this.readMemory( + 'uint32', + entries + (this.is64Bit ? 0x18 : 0xc) + ); + + for (let i = 0; i < Math.min(len, 32); i++) { + const keyPtr = + entries + + ((this.is64Bit ? 0x20 : 0x10) + i * (this.is64Bit ? 0x18 : 0x10)); + const valPtr = keyPtr + (this.is64Bit ? 0x10 : 0xc); + const key = this.readMemory('int32', keyPtr); + if (key === 14) { + const value = this.readMemory('ptr', valPtr); + switch (map) { + case MapType.POLUS: + case MapType.THE_SKELD: { + commsSabotaged = + this.readMemory( + 'uint32', + value, + this.offsets.commsSabotaged + ) === 1; + break; + } + case MapType.MIRA_HQ: { + commsSabotaged = + this.readMemory( + 'uint32', + value, + this.offsets.miraCompletedCommsConsoles + ) < 2; + } + } + } + } + } } if ( @@ -241,54 +302,15 @@ export default class GameReader { } this.lastPlayerPtr = allPlayers; - const inGame = - state === GameState.TASKS || - state === GameState.DISCUSSION || - state === GameState.LOBBY; - let newGameCode = 'MENU'; - if (state === GameState.LOBBY) { - newGameCode = this.readString( - this.readMemory( - 'int32', - this.gameAssembly.modBaseAddr, - offsets.gameCode - ) - ); - if (newGameCode) { - const split = newGameCode.split('\r\n'); - if (split.length === 2) { - newGameCode = split[1]; - } else { - newGameCode = ''; - } - if (!/^[A-Z]{6}$/.test(newGameCode) || newGameCode === 'MENU') { - newGameCode = ''; - } - } - // console.log(this.gameCode, newGameCode); - } else if (inGame) { - newGameCode = ''; - } - if (newGameCode) this.gameCode = newGameCode; - - const hostId = this.readMemory( - 'uint32', - this.gameAssembly.modBaseAddr, - offsets.hostId - ); - const clientId = this.readMemory( - 'uint32', - this.gameAssembly.modBaseAddr, - offsets.clientId - ); - const newState = { - lobbyCode: this.gameCode, + const newState: AmongUsState = { + lobbyCode: this.gameCode || 'MENU', players, gameState: state, oldGameState: this.oldGameState, isHost: (hostId && clientId && hostId === clientId) as boolean, hostId: hostId, clientId: clientId, + commsSabotaged, }; const stateHasChanged = !equal(this.lastState, newState); if (stateHasChanged) { @@ -300,7 +322,6 @@ export default class GameReader { } this.lastState = newState; this.oldGameState = state; - return null; // No error } return null; } @@ -309,29 +330,95 @@ export default class GameReader { this.sendIPC = sendIPC; } + initializeoffsets(): void { + this.is64Bit = this.isX64Version(); + this.offsets = this.is64Bit ? offsetStore.x64 : offsetStore.x86; + this.PlayerStruct = new Struct(); + for (const member of this.offsets.player.struct) { + if (member.type === 'SKIP' && member.skip) { + this.PlayerStruct = this.PlayerStruct.addMember( + Struct.TYPES.SKIP(member.skip), + member.name + ); + } else { + this.PlayerStruct = this.PlayerStruct.addMember( + Struct.TYPES[member.type] as ValueType, + member.name + ); + } + } + + const innerNetClient = this.findPattern( + this.offsets.signatures.innerNetClient.sig, + this.offsets.signatures.innerNetClient.patternOffset, + this.offsets.signatures.innerNetClient.addressOffset + ); + const meetingHud = this.findPattern( + this.offsets.signatures.meetingHud.sig, + this.offsets.signatures.meetingHud.patternOffset, + this.offsets.signatures.meetingHud.addressOffset + ); + const gameData = this.findPattern( + this.offsets.signatures.gameData.sig, + this.offsets.signatures.gameData.patternOffset, + this.offsets.signatures.gameData.addressOffset + ); + + this.offsets.meetingHud[0] = meetingHud; + this.offsets.exiledPlayerId[1] = meetingHud; + this.offsets.allPlayersPtr[0] = gameData; + this.offsets.gameState[0] = innerNetClient; + this.offsets.gameCode[0] = innerNetClient; + this.offsets.hostId[0] = innerNetClient; + this.offsets.clientId[0] = innerNetClient; + } + + isX64Version(): boolean { + if (!this.amongUs || !this.gameAssembly) return false; + + const optionalHeader_offset = readMemoryRaw( + this.amongUs.handle, + this.gameAssembly.modBaseAddr + 0x3c, + 'uint32' + ); + const optionalHeader_magic = readMemoryRaw( + this.amongUs.handle, + this.gameAssembly.modBaseAddr + optionalHeader_offset + 0x18, + 'short' + ); + return optionalHeader_magic === 0x20b; + } + readMemory( dataType: DataType, address: number, - offsets: number[], + offsets: number[] = [], defaultParam?: T ): T { if (!this.amongUs) return defaultParam as T; if (address === 0) return defaultParam as T; + dataType = + dataType == 'pointer' || dataType == 'ptr' + ? this.is64Bit + ? 'uint64' + : 'uint32' + : dataType; const { address: addr, last } = this.offsetAddress(address, offsets); if (addr === 0) return defaultParam as T; return readMemoryRaw(this.amongUs.handle, addr + last, dataType); } + offsetAddress( address: number, offsets: number[] ): { address: number; last: number } { if (!this.amongUs) throw 'Among Us not open? Weird error'; - address = address & 0xffffffff; + address = this.is64Bit ? address : address & 0xffffffff; for (let i = 0; i < offsets.length - 1; i++) { address = readMemoryRaw( this.amongUs.handle, address + offsets[i], - 'uint32' + this.is64Bit ? 'uint64' : 'uint32' ); if (address == 0) break; @@ -339,32 +426,92 @@ export default class GameReader { const last = offsets.length > 0 ? offsets[offsets.length - 1] : 0; return { address, last }; } + readString(address: number): string { if (address === 0 || !this.amongUs) return ''; const length = readMemoryRaw( this.amongUs.handle, - address + 0x8, + address + (this.is64Bit ? 0x10 : 0x8), 'int' ); - const buffer = readBuffer(this.amongUs.handle, address + 0xc, length << 1); + const buffer = readBuffer( + this.amongUs.handle, + address + (this.is64Bit ? 0x14 : 0xc), + length << 1 + ); return buffer.toString('binary').replace(/\0/g, ''); } + findPattern( + signature: string, + patternOffset = 0x1, + addressOffset = 0x0 + ): number { + if (!this.amongUs || !this.gameAssembly) return 0x0; + const signatureTypes = 0x0 | 0x2; + const instruction_location = findPatternRaw( + this.amongUs.handle, + 'GameAssembly.dll', + signature, + signatureTypes, + patternOffset, + 0x0 + ); + const offsetAddr = this.readMemory( + 'int', + this.gameAssembly.modBaseAddr, + [instruction_location] + ); + return this.is64Bit + ? offsetAddr + instruction_location + addressOffset + : offsetAddr - this.gameAssembly.modBaseAddr; + } + + IntToGameCode(input: number): string { + if (!input || input === 0 || input > -1000) return ''; + + const V2 = 'QWXRTYLPESDFGHUJKZOCVBINMA'; + const a = input & 0x3ff; + const b = (input >> 10) & 0xfffff; + return [ + V2[Math.floor(a % 26)], + V2[Math.floor(a / 26)], + V2[Math.floor(b % 26)], + V2[Math.floor((b / 26) % 26)], + V2[Math.floor((b / (26 * 26)) % 26)], + V2[Math.floor((b / (26 * 26 * 26)) % 26)], + ].join(''); + } + parsePlayer( ptr: number, buffer: Buffer, - { offsets }: IOffsets, - PlayerStruct: Struct - ): Player { - const { data } = PlayerStruct.report(buffer, 0, {}); + localClientId = -1 + ): Player | undefined { + if (!this.PlayerStruct || !this.offsets) return undefined; + + const { data } = this.PlayerStruct.report(buffer, 0, {}); + + if (this.is64Bit) { + data.objectPtr = this.readMemory('pointer', ptr, [ + this.PlayerStruct.getOffsetByName('objectPtr'), + ]); + data.name = this.readMemory('pointer', ptr, [ + this.PlayerStruct.getOffsetByName('name'), + ]); + } + + const clientId = this.readMemory( + 'uint32', + data.objectPtr, + this.offsets.player.clientId + ); - const isLocal = - this.readMemory('int', data.objectPtr, offsets.player.isLocal) !== - 0; + const isLocal = clientId === localClientId; const positionOffsets = isLocal - ? [offsets.player.localX, offsets.player.localY] - : [offsets.player.remoteX, offsets.player.remoteY]; + ? [this.offsets.player.localX, this.offsets.player.localY] + : [this.offsets.player.remoteX, this.offsets.player.remoteY]; const x = this.readMemory( 'float', @@ -376,9 +523,11 @@ export default class GameReader { data.objectPtr, positionOffsets[1] ); + return { ptr, id: data.id, + clientId: clientId, name: this.readString(data.name), colorId: data.color, hatId: data.hat, @@ -390,8 +539,11 @@ export default class GameReader { taskPtr: data.taskPtr, objectPtr: data.objectPtr, inVent: - this.readMemory('byte', data.objectPtr, offsets.player.inVent) > - 0, + this.readMemory( + 'byte', + data.objectPtr, + this.offsets.player.inVent + ) > 0, isLocal, x, y, diff --git a/src/main/hook.ts b/src/main/hook.ts index a853ec31..b4d6f4e1 100644 --- a/src/main/hook.ts +++ b/src/main/hook.ts @@ -44,22 +44,30 @@ ipcMain.handle(IpcHandlerMessages.START_HOOK, async (event) => { iohook.on('keydown', (ev: IOHookEvent) => { const shortcutKey = store.get('pushToTalkShortcut'); if (!isMouseButton(shortcutKey) && keyCodeMatches(shortcutKey as K, ev)) { - event.sender.send(IpcRendererMessages.PUSH_TO_TALK, true); + try { + event.sender.send(IpcRendererMessages.PUSH_TO_TALK, true); + } catch (_) {} } }); iohook.on('keyup', (ev: IOHookEvent) => { const shortcutKey = store.get('pushToTalkShortcut'); if (!isMouseButton(shortcutKey) && keyCodeMatches(shortcutKey as K, ev)) { - event.sender.send(IpcRendererMessages.PUSH_TO_TALK, false); + try { + event.sender.send(IpcRendererMessages.PUSH_TO_TALK, false); + } catch (_) {} } if ( !isMouseButton(store.get('deafenShortcut')) && keyCodeMatches(store.get('deafenShortcut') as K, ev) ) { - event.sender.send(IpcRendererMessages.TOGGLE_DEAFEN); + try { + event.sender.send(IpcRendererMessages.TOGGLE_DEAFEN); + } catch (_) {} } if (keyCodeMatches(store.get('muteShortcut', 'RAlt') as K, ev)) { - event.sender.send(IpcRendererMessages.TOGGLE_MUTE); + try { + event.sender.send(IpcRendererMessages.TOGGLE_MUTE); + } catch (_) {} } }); @@ -70,7 +78,9 @@ ipcMain.handle(IpcHandlerMessages.START_HOOK, async (event) => { isMouseButton(shortcutMouse) && mouseClickMatches(shortcutMouse as M, ev) ) { - event.sender.send(IpcRendererMessages.PUSH_TO_TALK, true); + try { + event.sender.send(IpcRendererMessages.PUSH_TO_TALK, true); + } catch (_) {} } }); iohook.on('mouseup', (ev: IOHookEvent) => { @@ -79,7 +89,25 @@ ipcMain.handle(IpcHandlerMessages.START_HOOK, async (event) => { isMouseButton(shortcutMouse) && mouseClickMatches(shortcutMouse as M, ev) ) { - event.sender.send(IpcRendererMessages.PUSH_TO_TALK, false); + try { + event.sender.send(IpcRendererMessages.PUSH_TO_TALK, false); + } catch (_) {} + } + if ( + isMouseButton(store.get('deafenShortcut')) && + mouseClickMatches(store.get('deafenShortcut') as M, ev) + ) { + try { + event.sender.send(IpcRendererMessages.TOGGLE_DEAFEN); + } catch (_) {} + } + if ( + isMouseButton(store.get('muteShortcut', 'RAlt')) && + mouseClickMatches(store.get('muteShortcut', 'RAlt') as M, ev) + ) { + try { + event.sender.send(IpcRendererMessages.TOGGLE_MUTE); + } catch (_) {} } }); diff --git a/src/main/index.ts b/src/main/index.ts index 061991b4..736781a4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,14 +6,26 @@ import windowStateKeeper from 'electron-window-state'; import { join as joinPath } from 'path'; import { format as formatUrl } from 'url'; import './hook'; +import { overlayWindow as electronOverlayWindow } from 'electron-overlay-window'; import { initializeIpcHandlers, initializeIpcListeners } from './ipc-handlers'; import { IpcRendererMessages } from '../common/ipc-messages'; import { ProgressInfo } from 'builder-util-runtime'; +import iohook from 'iohook'; const isDevelopment = process.env.NODE_ENV !== 'production'; -// global reference to mainWindow (necessary to prevent window from being garbage collected) -let mainWindow: BrowserWindow | null; +let mainWindow: BrowserWindow | null = null; +let overlayWindow: BrowserWindow | null = null; +let disableOverlay: boolean = false; +let setTimeoutValue: number = 0; + +if (app.commandLine.hasSwitch('disable-overlay')) { + disableOverlay = true; +} else { + if (process.platform === 'linux') { + setTimeoutValue = 1000; + } +} app.commandLine.appendSwitch('disable-pinch'); @@ -53,7 +65,7 @@ function createMainWindow() { if (isDevelopment) { crewlinkVersion = '1.2.0'; window.loadURL( - `http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}?version=DEV` + `http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}?version=DEV&view=app` ); } else { crewlinkVersion = autoUpdater.currentVersion.version; @@ -63,6 +75,7 @@ function createMainWindow() { protocol: 'file', query: { version: autoUpdater.currentVersion.version, + view: 'app', }, slashes: true, }) @@ -72,6 +85,14 @@ function createMainWindow() { window.on('closed', () => { mainWindow = null; + if (overlayWindow != null) { + try { + overlayWindow.close(); + } catch (_) { + console.error(_); + } + overlayWindow = null; + } }); window.webContents.on('devtools-opened', () => { @@ -84,6 +105,48 @@ function createMainWindow() { return window; } +function createOverlay() { + const window = new BrowserWindow({ + width: 400, + height: 300, + webPreferences: { + nodeIntegration: true, + webSecurity: false, + }, + ...electronOverlayWindow.WINDOW_OPTS, + }); + + if (isDevelopment) { + window.loadURL( + `http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}?version=${autoUpdater.currentVersion.version}&view=overlay` + ); + } else { + window.loadURL( + formatUrl({ + pathname: joinPath(__dirname, 'index.html'), + protocol: 'file', + query: { + version: autoUpdater.currentVersion.version, + view: 'overlay', + }, + slashes: true, + }) + ); + } + window.setIgnoreMouseEvents(true); + if (!disableOverlay) { + electronOverlayWindow.attachTo(window, 'Among Us'); + } + + if (isDevelopment) { + // Force devtools into detached mode otherwise they are unusable + window.webContents.openDevTools({ + mode: 'detach', + }); + } + return window; +} + const gotTheLock = app.requestSingleInstanceLock(); if (!gotTheLock) { app.quit(); @@ -154,10 +217,18 @@ if (!gotTheLock) { app.on('window-all-closed', () => { // on macOS it is common for applications to stay open until the user explicitly quits if (process.platform !== 'darwin') { + if (overlayWindow != null) { + overlayWindow.close(); + overlayWindow = null; + } app.quit(); } }); + app.on('before-quit', () => { + iohook.stop(); + }); + app.on('activate', () => { // on macOS it is common to re-create a window even after all windows have been closed if (mainWindow === null) { @@ -165,10 +236,21 @@ if (!gotTheLock) { } }); + // Only needed to get proper transparency for the overlay under Linux + if (!disableOverlay) { + app.disableHardwareAcceleration(); + } + // create main BrowserWindow when electron is ready app.whenReady().then(() => { - initializeIpcListeners(); - initializeIpcHandlers(); - mainWindow = createMainWindow(); + setTimeout( + function() { + mainWindow = createMainWindow(); + overlayWindow = createOverlay(); + initializeIpcListeners(overlayWindow); + initializeIpcHandlers(); + }, + setTimeoutValue + ) }); } diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 51d2dde7..12973910 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -3,10 +3,10 @@ import { HKEY, enumerateValues } from 'registry-js'; import spawn from 'cross-spawn'; import path from 'path'; -import { IpcMessages } from '../common/ipc-messages'; +import { IpcMessages, IpcOverlayMessages } from '../common/ipc-messages'; // Listeners are fire and forget, they do not have "responses" or return values -export const initializeIpcListeners = (): void => { +export const initializeIpcListeners = (overlayWindow: BrowserWindow): void => { ipcMain.on( IpcMessages.SHOW_ERROR_DIALOG, (e, opts: { title: string; content: string }) => { @@ -56,6 +56,13 @@ export const initializeIpcListeners = (): void => { } app.quit(); }); + + ipcMain.on( + IpcMessages.SEND_TO_OVERLAY, + (_, event: IpcOverlayMessages, ...args: unknown[]) => { + overlayWindow.webContents.send(event, ...args); + } + ); }; // Handlers are async cross-process instructions, they should have a return value diff --git a/src/main/memoryjs.d.ts b/src/main/memoryjs.d.ts index 94c1d262..2ad5f948 100644 --- a/src/main/memoryjs.d.ts +++ b/src/main/memoryjs.d.ts @@ -97,6 +97,15 @@ declare module 'memoryjs' { buffer: Buffer ): void; + export function findPattern( + handle: number, + moduleName: string, + signature: string, + signatureType: number, + patternOffset: number, + addressOffset: number + ): number; + // Functions // export enum ArgType { T_VOID, T_STRING, T_CHAR, T_BOOL, T_INT, T_DOUBLE, T_FLOAT } diff --git a/src/main/offsetStore.ts b/src/main/offsetStore.ts index 3623a1d7..dfa2c2b0 100644 --- a/src/main/offsetStore.ts +++ b/src/main/offsetStore.ts @@ -1,218 +1,211 @@ +export interface IOffsetsStore { + x64: IOffsets; + x86: IOffsets; +} + +interface ISignature { + sig: string; + addressOffset: number; + patternOffset: number; +} + export interface IOffsets { - versionNumber: string; - versionSource: 'steam' | 'itch' | 'windowsStore'; - offsets: { - meetingHud: number[]; - meetingHudCachePtr: number[]; - meetingHudState: number[]; - gameState: number[]; - allPlayersPtr: number[]; - allPlayers: number[]; - playerCount: number[]; - playerAddrPtr: number; - exiledPlayerId: number[]; - gameCode: number[]; - hostId: number[]; + meetingHud: number[]; + meetingHudCachePtr: number[]; + meetingHudState: number[]; + gameState: number[]; + allPlayersPtr: number[]; + allPlayers: number[]; + playerCount: number[]; + playerAddrPtr: number; + exiledPlayerId: number[]; + gameCode: number[]; + hostId: number[]; + clientId: number[]; + shipStatus: number[]; + shipStatusSystems: number[]; + shipStatusMap: number[]; + miraCompletedCommsConsoles: number[]; + commsSabotaged: number[]; + player: { + localX: number[]; + localY: number[]; + remoteX: number[]; + remoteY: number[]; + bufferLength: number; + offsets: number[]; + inVent: number[]; clientId: number[]; - player: { - isLocal: number[]; - localX: number[]; - localY: number[]; - remoteX: number[]; - remoteY: number[]; - bufferLength: number; - offsets: number[]; - inVent: number[]; - struct: { - type: - | 'INT' - | 'INT_BE' - | 'UINT' - | 'UINT_BE' - | 'SHORT' - | 'SHORT_BE' - | 'USHORT' - | 'USHORT_BE' - | 'FLOAT' - | 'CHAR' - | 'BYTE' - | 'SKIP'; - skip?: number; - name: string; - }[]; - }; + struct: { + type: + | 'INT' + | 'INT_BE' + | 'UINT' + | 'UINT_BE' + | 'SHORT' + | 'SHORT_BE' + | 'USHORT' + | 'USHORT_BE' + | 'FLOAT' + | 'CHAR' + | 'BYTE' + | 'SKIP'; + skip?: number; + name: string; + }[]; + }; + signatures: { + innerNetClient: ISignature; + meetingHud: ISignature; + gameData: ISignature; + shipStatus: ISignature; }; } export default { - 'lagz++MaYU+z5QoxU9US54EQe9HVGPo9rZ8DTisw8tc=': { - versionNumber: '2020.10.22', - versionSource: 'steam', - offsets: { - meetingHud: [21280716, 92, 0], - meetingHudCachePtr: [8], - meetingHudState: [132], - gameState: [21281584, 92, 0, 100], - hostId: [21281584, 92, 0, 68], - clientId: [21281584, 92, 0, 72], - allPlayersPtr: [21281328, 92, 0, 36], - allPlayers: [8], - playerCount: [12], - playerAddrPtr: 16, - exiledPlayerId: [255, 21280716, 92, 0, 148, 8], - gameCode: [20607324, 92, 0, 32, 40], - player: { - struct: [ - { - type: 'SKIP', - skip: 8, - name: 'unused', - }, - { - type: 'UINT', - name: 'id', - }, - { - type: 'UINT', - name: 'name', - }, - { - type: 'UINT', - name: 'color', - }, - { - type: 'UINT', - name: 'hat', - }, - { - type: 'UINT', - name: 'pet', - }, - { - type: 'UINT', - name: 'skin', - }, - { - type: 'UINT', - name: 'disconnected', - }, - { - type: 'UINT', - name: 'taskPtr', - }, - { - type: 'BYTE', - name: 'impostor', - }, - { - type: 'BYTE', - name: 'dead', - }, - { - type: 'SKIP', - skip: 2, - name: 'unused', - }, - { - type: 'UINT', - name: 'objectPtr', - }, - ], - isLocal: [84], - localX: [96, 80], - localY: [96, 84], - remoteX: [96, 60], - remoteY: [96, 64], - bufferLength: 56, - offsets: [0, 0], - inVent: [49], + x64: { + meetingHud: [0x21d03e0, 0xb8, 0], + meetingHudCachePtr: [0x10], + meetingHudState: [0xc0], + gameState: [0x21d0ea0, 0xb8, 0, 0xac], + gameCode: [0x21d0ea0, 0xb8, 0, 0x74], + hostId: [0x143be9c, 0xb8, 0, 0x78], + clientId: [0x143be9c, 0xb8, 0, 0x7c], + allPlayersPtr: [0x21d0e60, 0xb8, 0, 0x30], + allPlayers: [0x10], + playerCount: [0x18], + playerAddrPtr: 0x20, + exiledPlayerId: [0xff, 0x21d03e0, 0xb8, 0, 0xe0, 0x10], + shipStatus: [0x21d0ce0, 0xb8, 0x0], + shipStatusSystems: [0xc0], + shipStatusMap: [0x154], + miraCompletedCommsConsoles: [0x18, 0x20], // OAMJKPNKGBM + commsSabotaged: [0x10], + player: { + struct: [ + { type: 'SKIP', skip: 16, name: 'unused' }, + { type: 'UINT', name: 'id' }, + { type: 'SKIP', skip: 4, name: 'unused' }, + { type: 'UINT', name: 'name' }, + { type: 'SKIP', skip: 4, name: 'unused' }, + { type: 'UINT', name: 'color' }, + { type: 'UINT', name: 'hat' }, + { type: 'UINT', name: 'pet' }, + { type: 'UINT', name: 'skin' }, + { type: 'UINT', name: 'disconnected' }, + { type: 'SKIP', skip: 4, name: 'unused' }, + { type: 'UINT', name: 'taskPtr' }, + { type: 'SKIP', skip: 4, name: 'unused' }, + { type: 'BYTE', name: 'impostor' }, + { type: 'BYTE', name: 'dead' }, + { type: 'SKIP', skip: 6, name: 'unused' }, + { type: 'UINT', name: 'objectPtr' }, + { type: 'SKIP', skip: 4, name: 'unused' }, + ], + localX: [144, 108], + localY: [144, 112], + remoteX: [144, 88], + remoteY: [144, 92], + bufferLength: 80, + offsets: [0, 0], + inVent: [61], + clientId: [40], + }, + signatures: { + innerNetClient: { + sig: + '48 8B 05 ? ? ? ? 48 8B 88 ? ? ? ? 48 8B 01 48 85 C0 0F 84 ? ? ? ? 66 66 66 0F 1F 84 00 ? ? ? ?', + patternOffset: 3, + addressOffset: 4, + }, + meetingHud: { + sig: + '48 8B 05 ? ? ? ? 48 8B 88 ? ? ? ? 74 72 48 8B 39 48 8B 0D ? ? ? ? F6 81 ? ? ? ? ?', + patternOffset: 3, + addressOffset: 4, + }, + gameData: { + sig: + '48 8B 05 ? ? ? ? 48 8B 88 ? ? ? ? 48 8B 01 48 85 C0 0F 84 ? ? ? ? BE ? ? ? ?', + patternOffset: 3, + addressOffset: 4, + }, + shipStatus: { + sig: + '48 8B 05 ? ? ? ? 48 8B 5C 24 ? 48 8B 6C 24 ? 48 8B 74 24 ? 48 8B 88 ? ? ? ? 48 89 39 48 83 C4 20 5F', + patternOffset: 3, + addressOffset: 4, }, }, }, - 'CwEL0xldOcCJ3AGNg0suvSa6Z9L0nE6+pgioBPwJdbc=': { - versionNumber: '2020.12.9', - versionSource: 'steam', - offsets: { - meetingHud: [29717412, 92, 0], - meetingHudCachePtr: [8], - meetingHudState: [132], - gameState: [29720404, 92, 0, 100], - hostId: [29720404, 92, 0, 68], - clientId: [29720404, 92, 0, 72], - allPlayersPtr: [29719528, 92, 0, 36], - allPlayers: [8], - playerCount: [12], - playerAddrPtr: 16, - exiledPlayerId: [255, 29717412, 92, 0, 148, 8], - gameCode: [28254460, 92, 0, 32, 40], - player: { - struct: [ - { - type: 'SKIP', - skip: 8, - name: 'unused', - }, - { - type: 'UINT', - name: 'id', - }, - { - type: 'UINT', - name: 'name', - }, - { - type: 'UINT', - name: 'color', - }, - { - type: 'UINT', - name: 'hat', - }, - { - type: 'UINT', - name: 'pet', - }, - { - type: 'UINT', - name: 'skin', - }, - { - type: 'UINT', - name: 'disconnected', - }, - { - type: 'UINT', - name: 'taskPtr', - }, - { - type: 'BYTE', - name: 'impostor', - }, - { - type: 'BYTE', - name: 'dead', - }, - { - type: 'SKIP', - skip: 2, - name: 'unused', - }, - { - type: 'UINT', - name: 'objectPtr', - }, - ], - isLocal: [84], - localX: [96, 80], - localY: [96, 84], - remoteX: [96, 60], - remoteY: [96, 64], - bufferLength: 56, - offsets: [0, 0], - inVent: [49], + x86: { + meetingHud: [0x1c573a4, 0x5c, 0], + meetingHudCachePtr: [0x8], + meetingHudState: [0x84], + gameState: [0x1c57f54, 0x5c, 0, 0x64], + gameCode: [0x1c57f54, 0x5c, 0, 0x40], + hostId: [0x1c57f54, 0x5c, 0, 0x44], + clientId: [0x1c57f54, 0x5c, 0, 0x48], + allPlayersPtr: [0x1c57be8, 0x5c, 0, 0x24], + allPlayers: [0x08], + playerCount: [0x0c], + playerAddrPtr: 0x10, + exiledPlayerId: [0xff, 0x1c573a4, 0x5c, 0, 0x94, 0x08], + shipStatus: [0x1c57cac, 0x5c, 0x0], + shipStatusSystems: [0x84], + shipStatusMap: [0xd4], + miraCompletedCommsConsoles: [0xc, 0x10], // OAMJKPNKGBM + commsSabotaged: [0x8], + player: { + struct: [ + { type: 'SKIP', skip: 8, name: 'unused' }, + { type: 'UINT', name: 'id' }, + { type: 'UINT', name: 'name' }, + { type: 'UINT', name: 'color' }, + { type: 'UINT', name: 'hat' }, + { type: 'UINT', name: 'pet' }, + { type: 'UINT', name: 'skin' }, + { type: 'UINT', name: 'disconnected' }, + { type: 'UINT', name: 'taskPtr' }, + { type: 'BYTE', name: 'impostor' }, + { type: 'BYTE', name: 'dead' }, + { type: 'SKIP', skip: 2, name: 'unused' }, + { type: 'UINT', name: 'objectPtr' }, + ], + localX: [96, 80], + localY: [96, 84], + remoteX: [96, 60], + remoteY: [96, 64], + bufferLength: 56, + offsets: [0, 0], + inVent: [49], + clientId: [28], + }, + signatures: { + innerNetClient: { + sig: + '8B 0D ? ? ? ? 83 C4 08 8B F0 8B 49 5C 8B 11 85 D2 74 15 8B 4D 0C 8B 49 18 8B 01 50 56 52 8B 00 FF D0', + patternOffset: 2, + addressOffset: 0, + }, + meetingHud: { + sig: + 'A1 ? ? ? ? 56 8B 40 5C 8B 30 A1 ? ? ? ? F6 80 ? ? ? ? ? 74 0F 83 78 74 00 75 09 50 E8 ? ? ? ? 83 C4 04 6A 00 56 E8 ? ? ? ? 83 C4 08 84 C0 0F 85 ? ? ? ? 57 8B 7D 0C 6A 00 57 FF 35 ? ? ? ? E8 ? ? ? ? 8B 0D ? ? ? ? 83 C4 0C 8B F0 F6 81 ? ? ? ? ?', + patternOffset: 1, + addressOffset: 0, + }, + gameData: { + sig: + '8B 0D ? ? ? ? 8B F0 83 C4 10 8B 49 5C 8B 01 85 C0 0F 84 ? ? ? ? 6A 00 FF 75 F4 50 E8 ? ? ? ? 83 C4 0C 89 45 E8 85 C0', + patternOffset: 2, + addressOffset: 0, + }, + shipStatus: { + sig: + 'A1 ? ? ? ? 8B 40 5C 8B 00 85 C0 74 5A 8B 80 ? ? ? ? 85 C0 74 50 6A 00 6A 00', + patternOffset: 1, + addressOffset: 0, }, }, }, -} as { - [dllHash: string]: IOffsets; -}; +} as IOffsetsStore; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 587e96de..a6903375 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,7 @@ import React, { Dispatch, + ErrorInfo, + ReactChild, SetStateAction, useEffect, useReducer, @@ -24,6 +26,7 @@ import { AutoUpdaterState, IpcHandlerMessages, IpcMessages, + IpcOverlayMessages, IpcRendererMessages, IpcSyncMessages, } from '../common/ipc-messages'; @@ -40,6 +43,9 @@ import DialogContentText from '@material-ui/core/DialogContentText'; import DialogActions from '@material-ui/core/DialogActions'; import Button from '@material-ui/core/Button'; import prettyBytes from 'pretty-bytes'; +import './css/index.css'; +import Typography from '@material-ui/core/Typography'; +import SupportLink from './SupportLink'; let appVersion = ''; if (typeof window !== 'undefined' && window.location) { @@ -111,7 +117,67 @@ enum AppState { VOICE, } -function App() { +interface ErrorBoundaryProps { + children: ReactChild; +} +interface ErrorBoundaryState { + error?: Error; +} + +class ErrorBoundary extends React.Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = {}; + } + + static getDerivedStateFromError(error: Error): ErrorBoundaryState { + // Update state so the next render will show the fallback UI. + return { error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('React Error: ', error, errorInfo); + } + + render(): ReactChild { + if (this.state.error) { + return ( +
+ + REACT ERROR + + + {this.state.error.stack} + + + +
+ ); + } + + return this.props.children; + } +} + +const App: React.FC = function () { const [state, setState] = useState(AppState.MENU); const [gameState, setGameState] = useState({} as AmongUsState); const [settingsOpen, setSettingsOpen] = useState(false); @@ -130,8 +196,13 @@ function App() { muteShortcut: 'RAlt', hideCode: false, enableSpatialAudio: true, + meetingOverlay: true, + overlayPosition: 'right', localLobbySettings: { maxDistance: 5.32, + haunting: false, + hearImpostorsInVents: false, + commsSabotage: true, }, }); const lobbySettings = useReducer( @@ -189,6 +260,22 @@ function App() { }; }, []); + useEffect(() => { + ipcRenderer.send( + IpcMessages.SEND_TO_OVERLAY, + IpcOverlayMessages.NOTIFY_GAME_STATE_CHANGED, + gameState + ); + }, [gameState]); + + useEffect(() => { + ipcRenderer.send( + IpcMessages.SEND_TO_OVERLAY, + IpcOverlayMessages.NOTIFY_SETTINGS_CHANGED, + settings[0] + ); + }, [settings]); + let page; switch (state) { case AppState.MENU: @@ -208,51 +295,55 @@ function App() { settingsOpen={settingsOpen} setSettingsOpen={setSettingsOpen} /> - setSettingsOpen(false)} - /> - - Updating... - - {(updaterState.state === 'downloading' || - updaterState.state === 'downloaded') && - updaterState.progress && ( - <> - - - {prettyBytes(updaterState.progress.transferred)} /{' '} - {prettyBytes(updaterState.progress.total)} + + <> + setSettingsOpen(false)} + /> + + Updating... + + {(updaterState.state === 'downloading' || + updaterState.state === 'downloaded') && + updaterState.progress && ( + <> + + + {prettyBytes(updaterState.progress.transferred)} /{' '} + {prettyBytes(updaterState.progress.total)} + + + )} + {updaterState.state === 'error' && ( + + {updaterState.error} - + )} + + {updaterState.state === 'error' && ( + + + )} - {updaterState.state === 'error' && ( - - {updaterState.error} - - )} - - {updaterState.state === 'error' && ( - - - - )} - - {page} + + {page} + + ); -} +}; ReactDOM.render(, document.getElementById('app')); diff --git a/src/renderer/Avatar.tsx b/src/renderer/Avatar.tsx index ca55e6f5..e1fb0578 100644 --- a/src/renderer/Avatar.tsx +++ b/src/renderer/Avatar.tsx @@ -58,6 +58,7 @@ export interface AvatarProps { deafened?: boolean; muted?: boolean; connectionState?: 'disconnected' | 'novoice' | 'connected'; + style?: React.CSSProperties; } const Avatar: React.FC = function ({ @@ -69,6 +70,7 @@ const Avatar: React.FC = function ({ player, size, connectionState, + style, }: AvatarProps) { const status = isAlive ? 'alive' : 'dead'; let image = players[status][player.colorId]; @@ -103,7 +105,7 @@ const Avatar: React.FC = function ({ return ( -
+
({ display: 'flex', justifyContent: 'space-evenly', margin: 5, + '&>svg': { + cursor: 'pointer', + }, }, })); diff --git a/src/renderer/Overlay.tsx b/src/renderer/Overlay.tsx new file mode 100644 index 00000000..036b03ea --- /dev/null +++ b/src/renderer/Overlay.tsx @@ -0,0 +1,282 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { ipcRenderer } from 'electron'; +import { + AmongUsState, + GameState, + VoiceState, + OtherTalking, +} from '../common/AmongUsState'; +import { IpcOverlayMessages } from '../common/ipc-messages'; +import ReactDOM from 'react-dom'; +import makeStyles from '@material-ui/core/styles/makeStyles'; +import './css/overlay.css'; +import Avatar from './Avatar'; +import { ISettings } from '../common/ISettings'; + +interface UseStylesProps { + hudHeight: number; +} + +const useStyles = makeStyles(() => ({ + meetingHud: { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + }, + playerIcons: { + width: '83.45%', + height: '63.2%', + left: '5%', + top: '18.4703%', + position: 'absolute', + display: 'flex', + '&>*:nth-child(odd)': { + marginRight: '1.4885%', + }, + '&>*:nth-child(even)': { + marginLeft: '1.4885%', + }, + flexWrap: 'wrap', + }, + icon: { + width: '48.51%', + height: '16.49%', + borderRadius: ({ hudHeight }: UseStylesProps) => hudHeight / 100, + transition: 'opacity .1s linear', + marginBottom: '2.25%', + boxSizing: 'border-box', + }, +})); + +function useWindowSize() { + const [windowSize, setWindowSize] = useState<[number, number]>([0, 0]); + + useEffect(() => { + const onResize = () => { + setWindowSize([window.innerWidth, window.innerHeight]); + }; + window.addEventListener('resize', onResize); + onResize(); + + return () => window.removeEventListener('resize', onResize); + }, []); + return windowSize; +} + +const playerColors = [ + ['#C51111', '#7A0838'], + ['#132ED1', '#09158E'], + ['#117F2D', '#0A4D2E'], + ['#ED54BA', '#AB2BAD'], + ['#EF7D0D', '#B33E15'], + ['#F5F557', '#C38823'], + ['#3F474E', '#1E1F26'], + ['#8394BF', '#8394BF'], + ['#6B2FBB', '#3B177C'], + ['#71491E', '#5E2615'], + ['#38FEDC', '#24A8BE'], + ['#50EF39', '#15A742'], +]; + +const iPadRatio = 854 / 579; + +const Overlay: React.FC = function () { + const [gameState, setGameState] = useState( + (undefined as unknown) as AmongUsState + ); + const [voiceState, setVoiceState] = useState( + (undefined as unknown) as VoiceState + ); + const [settings, setSettings] = useState( + (undefined as unknown) as ISettings + ); + useEffect(() => { + const onState = (_: Electron.IpcRendererEvent, newState: AmongUsState) => { + setGameState(newState); + }; + const onVoiceState = ( + _: Electron.IpcRendererEvent, + newState: VoiceState + ) => { + setVoiceState(newState); + }; + const onSettings = (_: Electron.IpcRendererEvent, newState: ISettings) => { + setSettings(newState); + }; + ipcRenderer.on(IpcOverlayMessages.NOTIFY_GAME_STATE_CHANGED, onState); + ipcRenderer.on(IpcOverlayMessages.NOTIFY_VOICE_STATE_CHANGED, onVoiceState); + ipcRenderer.on(IpcOverlayMessages.NOTIFY_SETTINGS_CHANGED, onSettings); + return () => { + ipcRenderer.off(IpcOverlayMessages.NOTIFY_GAME_STATE_CHANGED, onState); + ipcRenderer.off( + IpcOverlayMessages.NOTIFY_VOICE_STATE_CHANGED, + onVoiceState + ); + ipcRenderer.off(IpcOverlayMessages.NOTIFY_SETTINGS_CHANGED, onSettings); + }; + }, []); + + if (!settings || !voiceState || !gameState) return null; + return ( + <> + {settings.meetingOverlay && ( + + )} + {settings.overlayPosition !== 'hidden' && ( + + )} + + ); +}; + +interface AvatarOverlayProps { + voiceState: VoiceState; + gameState: AmongUsState; + position: ISettings['overlayPosition']; +} + +const useOverlayStyles = makeStyles((theme) => ({ + root: { + width: '5%', + position: 'absolute', + background: '#25232ac0', + padding: theme.spacing(2), + '&>*': { + marginTop: 4, + marginBottom: 4, + }, + }, +})); +const AvatarOverlay: React.FC = ({ + voiceState, + gameState, + position, +}: AvatarOverlayProps) => { + if (!gameState.players) return null; + const classes = useOverlayStyles({ position }); + const avatars: JSX.Element[] = []; + + gameState.players.forEach((player) => { + if (!voiceState.otherTalking[player.id]) return; + const peer = voiceState.playerSocketIds[player.id]; + const connected = Object.values(voiceState.socketClients) + .map(({ playerId }) => playerId) + .includes(player.id); + const audio = voiceState.audioConnected[peer]; + avatars.push( + + ); + }); + if (avatars.length === 0) return null; + return ( +
+ {avatars} +
+ ); +}; + +interface MeetingHudProps { + otherTalking: OtherTalking; + gameState: AmongUsState; +} + +const MeetingHud: React.FC = ({ + otherTalking, + gameState, +}: MeetingHudProps) => { + const [width, height] = useWindowSize(); + + let hudWidth = 0, + hudHeight = 0; + if (width / (height * 0.96) > iPadRatio) { + hudHeight = height * 0.96; + hudWidth = hudHeight * iPadRatio; + } else { + hudWidth = width; + hudHeight = width * (1 / iPadRatio); + } + const classes = useStyles({ hudHeight }); + const players = useMemo(() => { + if (!gameState.players) return null; + return gameState.players.sort((a, b) => { + if ((a.disconnected || a.isDead) && (b.disconnected || b.isDead)) { + return a.id - b.id; + } else if (a.disconnected || a.isDead) { + return 1000; + } else if (b.disconnected || b.isDead) { + return -1000; + } + return a.id - b.id; + }); + }, [gameState.players]); + if (!players || gameState.gameState !== GameState.DISCUSSION) return null; + const overlays = gameState.players.map((player) => { + return ( +
+ ); + }); + + while (overlays.length < 10) { + overlays.push( +
+ ); + } + + return ( +
+
{overlays}
+
+ ); +}; + +ReactDOM.render(, document.getElementById('app')); + +export default Overlay; diff --git a/src/renderer/Voice.tsx b/src/renderer/Voice.tsx index d830c868..77278291 100644 --- a/src/renderer/Voice.tsx +++ b/src/renderer/Voice.tsx @@ -6,17 +6,32 @@ import { LobbySettingsContext, SettingsContext, } from './contexts'; -import { AmongUsState, GameState, Player } from '../common/AmongUsState'; +import { + AmongUsState, + AudioConnected, + Client, + GameState, + OtherTalking, + Player, + SocketClientMap, + VoiceState, +} from '../common/AmongUsState'; import Peer from 'simple-peer'; import { ipcRenderer } from 'electron'; import VAD from './vad'; -import { ISettings } from '../common/ISettings'; -import { IpcRendererMessages } from '../common/ipc-messages'; +import { ILobbySettings, ISettings } from '../common/ISettings'; +import { + IpcMessages, + IpcOverlayMessages, + IpcRendererMessages, +} from '../common/ipc-messages'; import Typography from '@material-ui/core/Typography'; import Grid from '@material-ui/core/Grid'; import makeStyles from '@material-ui/core/styles/makeStyles'; import SupportLink from './SupportLink'; import Divider from '@material-ui/core/Divider'; +// @ts-ignore +import reverbOgx from 'arraybuffer-loader!../../static/reverb.ogx'; export interface ExtendedAudioElement extends HTMLAudioElement { setSinkId: (sinkId: string) => Promise; @@ -38,16 +53,18 @@ type PeerErrorCode = | 'ERR_DATA_CHANNEL' | 'ERR_CONNECTION_FAILURE'; -interface AudioElements { - [peer: string]: { - element: HTMLAudioElement; - gain: GainNode; - pan: PannerNode; - }; +interface AudioNodes { + element: HTMLAudioElement; + gain: GainNode; + pan: PannerNode; + reverbGain: GainNode; + reverb: ConvolverNode; + compressor: DynamicsCompressorNode; + muffle: BiquadFilterNode; } -interface SocketClientMap { - [socketId: string]: Client; +interface AudioElements { + [peer: string]: AudioNodes; } interface ConnectionStuff { @@ -58,23 +75,6 @@ interface ConnectionStuff { muted: boolean; } -interface OtherTalking { - [playerId: number]: boolean; // isTalking -} - -interface OtherDead { - [playerId: number]: boolean; // isTalking -} - -interface AudioConnected { - [peer: string]: boolean; // isConnected -} - -interface Client { - playerId: number; - clientId: number; -} - interface SocketError { message?: string; } @@ -82,58 +82,112 @@ interface SocketError { function calculateVoiceAudio( state: AmongUsState, settings: ISettings, + lobbySettings: ILobbySettings, me: Player, other: Player, - gain: GainNode, - pan: PannerNode + audio: AudioNodes ): void { + const { pan, gain, muffle, reverbGain } = audio; const audioContext = pan.context; - pan.positionZ.setValueAtTime(-0.5, audioContext.currentTime); let panPos = [other.x - me.x, other.y - me.y]; - if ( - state.gameState === GameState.DISCUSSION || - (state.gameState === GameState.LOBBY && !settings.enableSpatialAudio) - ) { - panPos = [0, 0]; + + reverbGain.gain.value = 0; + + switch (state.gameState) { + case GameState.MENU: + gain.gain.value = 0; + break; + + case GameState.LOBBY: + gain.gain.value = 1; + break; + + case GameState.TASKS: + gain.gain.value = 1; + + // Mute all alive crewmates when comms is sabotaged + if ( + !me.isDead && + lobbySettings.commsSabotage && + state.commsSabotaged && + !me.isImpostor + ) { + gain.gain.value = 0; + } + + // Mute other players which are in a vent + if (other.inVent && !lobbySettings.hearImpostorsInVents) { + gain.gain.value = 0; + } + + // Mute dead players for still living players + if (!me.isDead && other.isDead) { + gain.gain.value = 0; + } + + // Haunting + if ( + !me.isDead && + me.isImpostor && + other.isDead && + !other.isImpostor && + lobbySettings.haunting + ) { + gain.gain.value = 0.075; + reverbGain.gain.value = 1; + } + + break; + case GameState.DISCUSSION: + panPos = [0, 0]; + gain.gain.value = 1; + + // Mute dead players for still living players + if (!me.isDead && other.isDead) { + gain.gain.value = 0; + } + + break; + case GameState.UNKNOWN: + default: + gain.gain.value = 0; + break; + } + + // Muffling in vents + if (me.inVent || other.inVent) { + muffle.frequency.value = 1200; + muffle.Q.value = 20; + if (gain.gain.value === 1) gain.gain.value = 0.7; // Too loud at 1 + } else { + muffle.frequency.value = 20000; + muffle.Q.value = 0; } + + // Clamp panning position if (isNaN(panPos[0])) panPos[0] = 999; if (isNaN(panPos[1])) panPos[1] = 999; - panPos[0] = Math.min(999, Math.max(-999, panPos[0])); - panPos[1] = Math.min(999, Math.max(-999, panPos[1])); - if (other.inVent) { - gain.gain.value = 0; - return; - } - if (me.isDead && other.isDead) { - gain.gain.value = 1; - pan.positionX.setValueAtTime(panPos[0], audioContext.currentTime); - pan.positionY.setValueAtTime(panPos[1], audioContext.currentTime); - return; - } - if (!me.isDead && other.isDead) { - gain.gain.value = 0; - return; - } + + panPos[0] = Math.min(Math.max(panPos[0], -999), 999); + panPos[1] = Math.min(Math.max(panPos[1], -999), 999); + + // Mute players if distance between two players is too big if ( - state.gameState === GameState.LOBBY || - state.gameState === GameState.DISCUSSION + Math.pow(panPos[0], 2) + Math.pow(panPos[1], 2) > + pan.maxDistance * pan.maxDistance ) { - gain.gain.value = 1; - pan.positionX.setValueAtTime(panPos[0], audioContext.currentTime); - pan.positionY.setValueAtTime(panPos[1], audioContext.currentTime); - } else if (state.gameState === GameState.TASKS) { - gain.gain.value = 1; - pan.positionX.setValueAtTime(panPos[0], audioContext.currentTime); - pan.positionY.setValueAtTime(panPos[1], audioContext.currentTime); - } else { gain.gain.value = 0; } - if ( - gain.gain.value === 1 && - Math.sqrt(Math.pow(panPos[0], 2) + Math.pow(panPos[1], 2)) > 7 - ) { - gain.gain.value = 0; + + // Reset panning position if the setting is disabled + if (!settings.enableSpatialAudio) { + panPos = [0, 0]; } + + // Apply position's to PanNode + pan.positionX.setValueAtTime(panPos[0], audioContext.currentTime); + pan.positionY.setValueAtTime(panPos[1], audioContext.currentTime); + pan.positionZ.setValueAtTime(-0.5, audioContext.currentTime); } export interface VoiceProps { @@ -206,7 +260,8 @@ const Voice: React.FC = function ({ const [lobbySettings, setLobbySettings] = useContext(LobbySettingsContext); const lobbySettingsRef = useRef(lobbySettings); const gameState = useContext(GameStateContext); - let { lobbyCode: displayedLobbyCode } = gameState; + let displayedLobbyCode = ''; + if (gameState) displayedLobbyCode = gameState.lobbyCode; if (displayedLobbyCode !== 'MENU' && settings.hideCode) displayedLobbyCode = 'LOBBY'; const [talking, setTalking] = useState(false); @@ -217,10 +272,11 @@ const Voice: React.FC = function ({ connect: (lobbyCode: string, playerId: number, clientId: number) => void; } | null>(null); const [otherTalking, setOtherTalking] = useState({}); - const [otherDead, setOtherDead] = useState({}); + const [otherDead, setOtherDead] = useState({}); const audioElements = useRef({}); const [audioConnected, setAudioConnected] = useState({}); const classes = useStyles(); + const convolverBuffer = useRef(null); const [deafenedState, setDeafened] = useState(false); const [mutedState, setMuted] = useState(false); @@ -235,13 +291,27 @@ const Voice: React.FC = function ({ delete connections[peer]; return connections; }); - if (audioElements.current[peer]) { - document.body.removeChild(audioElements.current[peer].element); - audioElements.current[peer].pan.disconnect(); - audioElements.current[peer].gain.disconnect(); + const audio = audioElements.current[peer]; + if (audio) { + document.body.removeChild(audio.element); + audio.pan.disconnect(); + audio.muffle.disconnect(); + audio.gain.disconnect(); + audio.reverb?.disconnect(); + audio.reverbGain?.disconnect(); + audio.compressor.disconnect(); delete audioElements.current[peer]; } } + + useEffect(() => { + (async () => { + const context = new AudioContext(); + convolverBuffer.current = await context.decodeAudioData(reverbOgx); + await context.close(); + })(); + }, []); + // Handle pushToTalk, if set useEffect(() => { if (!connectionStuff.current.stream) return; @@ -267,6 +337,24 @@ const Voice: React.FC = function ({ } }, [lobbySettings.maxDistance]); + useEffect(() => { + for (const peer in audioElements.current) { + const audio = audioElements.current[peer]; + if (lobbySettings.haunting) { + audio.gain.disconnect(); + audio.gain.connect(audio.compressor); + audio.gain.connect(audio.reverbGain); + audio.reverbGain.connect(audio.reverb); + audio.reverb.connect(audio.compressor); + } else { + audio.gain.disconnect(); + audio.gain.connect(audio.compressor); + audio.reverbGain.disconnect(); + audio.reverb.disconnect(); + } + } + }, [lobbySettings.haunting]); + // Add settings to settingsRef useEffect(() => { settingsRef.current = settings; @@ -430,7 +518,7 @@ const Voice: React.FC = function ({ } } }); - connection.on('stream', (stream: MediaStream) => { + connection.on('stream', async (stream: MediaStream) => { setAudioConnected((old) => ({ ...old, [peer]: true })); const audio = document.createElement( 'audio' @@ -443,6 +531,7 @@ const Voice: React.FC = function ({ const context = new AudioContext(); const source = context.createMediaStreamSource(stream); const gain = context.createGain(); + gain.gain.value = 0; // Mute to start, workaround to address duplicate sources aka "can be heard anywhere" const pan = context.createPanner(); pan.refDistance = 0.1; pan.panningModel = 'equalpower'; @@ -450,23 +539,53 @@ const Voice: React.FC = function ({ pan.maxDistance = lobbySettingsRef.current.maxDistance; pan.rolloffFactor = 1; + const muffle = context.createBiquadFilter(); + muffle.type = 'lowpass'; + + const compressor = context.createDynamicsCompressor(); + source.connect(pan); - pan.connect(gain); - // Source -> pan -> gain -> VAD -> destination - VAD(context, gain, context.destination, { + pan.connect(muffle); + muffle.connect(gain); + + const reverb = context.createConvolver(); + const reverbGain = context.createGain(); + reverbGain.gain.value = 0; + reverb.buffer = convolverBuffer.current; + + if (lobbySettingsRef.current.haunting) { + gain.connect(compressor); + gain.connect(reverbGain); + reverbGain.connect(reverb); + reverb.connect(compressor); + } else { + gain.connect(compressor); + } + + // Source -> pan -> muffle -> gain -> VAD -> destination + VAD(context, compressor, context.destination, { onVoiceStart: () => setTalking(true), onVoiceStop: () => setTalking(false), stereo: settingsRef.current.enableSpatialAudio, }); const setTalking = (talking: boolean) => { + if (!socketClientsRef.current[peer]) return; setOtherTalking((old) => ({ ...old, [socketClientsRef.current[peer].playerId]: talking && gain.gain.value > 0, })); }; - audioElements.current[peer] = { element: audio, gain, pan }; + audioElements.current[peer] = { + element: audio, + gain, + pan, + muffle, + reverb, + reverbGain, + compressor, + }; }); connection.on('signal', (data) => { socket.emit('signal', { @@ -544,7 +663,7 @@ const Voice: React.FC = function ({ disconnectPeer(k); }); connectionStuff.current.socket?.close(); - audioListener.destroy(); + audioListener?.destroy(); }; }, []); @@ -579,10 +698,10 @@ const Voice: React.FC = function ({ calculateVoiceAudio( gameState, settingsRef.current, + lobbySettingsRef.current, myPlayer, player, - audio.gain, - audio.pan + audio ); if (connectionStuff.current.deafened) { audio.gain.gain.value = 0; @@ -657,6 +776,22 @@ const Voice: React.FC = function ({ if (socketClients[k].playerId !== undefined) playerSocketIds[socketClients[k].playerId] = k; } + + // Pass voice state to overlay + useEffect(() => { + ipcRenderer.send( + IpcMessages.SEND_TO_OVERLAY, + IpcOverlayMessages.NOTIFY_VOICE_STATE_CHANGED, + { + otherTalking, + playerSocketIds, + otherDead, + socketClients, + audioConnected, + } as VoiceState + ); + }, [otherTalking, playerSocketIds, otherDead, socketClients, audioConnected]); + return (
{error && ( diff --git a/src/renderer/css/index.css b/src/renderer/css/index.css index 13d7ce80..0c73a232 100644 --- a/src/renderer/css/index.css +++ b/src/renderer/css/index.css @@ -15,6 +15,7 @@ body { ::-webkit-scrollbar { width: 8px; + height: 8px; margin-left: 2px; } @@ -27,6 +28,10 @@ body { border-radius: 5px; } +::-webkit-scrollbar-corner { + opacity: 0; +} + ::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.3); } \ No newline at end of file diff --git a/src/renderer/css/overlay.css b/src/renderer/css/overlay.css new file mode 100644 index 00000000..7f50abaf --- /dev/null +++ b/src/renderer/css/overlay.css @@ -0,0 +1,5 @@ +body, #app { + width: 100vw; + height: 100vh; + margin: 0; +} \ No newline at end of file diff --git a/src/renderer/index.ts b/src/renderer/index.ts index 34f6228b..5329ebc4 100644 --- a/src/renderer/index.ts +++ b/src/renderer/index.ts @@ -1,2 +1,9 @@ -import './App'; -import './css/index.css'; +if (typeof window !== 'undefined' && window.location) { + const query = new URLSearchParams(window.location.search.substring(1)); + const view = query.get('view') || 'app'; + if (view === 'app') { + import('./App'); + } else { + import('./Overlay'); + } +} diff --git a/src/renderer/settings/Settings.tsx b/src/renderer/settings/Settings.tsx index 482534d6..a4ac20a8 100644 --- a/src/renderer/settings/Settings.tsx +++ b/src/renderer/settings/Settings.tsx @@ -132,7 +132,7 @@ const keys = new Set([ 'RAlt', ]); -const store = new Store({ +const storeConfig: Store.Options = { migrations: { '1.1.3': (store) => { const serverIP = store.get('serverIP'); @@ -221,13 +221,39 @@ const store = new Store({ type: 'number', default: 5.32, }, + haunting: { + type: 'boolean', + default: false, + }, + hearImpostorsInVents: { + type: 'boolean', + default: false, + }, + commsSabotage: { + type: 'boolean', + default: true, + }, }, default: { maxDistance: 5.32, + haunting: false, + hearImpostorsInVents: false, + commsSabotage: true, }, }, + meetingOverlay: { + type: 'boolean', + default: true, + }, + overlayPosition: { + type: 'string', + enum: ['left', 'right', 'hidden'], + default: 'right', + }, }, -}); +}; + +const store = new Store(storeConfig); export interface SettingsProps { open: boolean; @@ -324,11 +350,7 @@ const URLInput: React.FC = function ({ return ( <> - setOpen(false)}> @@ -575,6 +597,99 @@ const Settings: React.FC = function ({ }} /> + + { + setSettings({ + type: 'setLobbySetting', + action: ['haunting', checked], + }); + if (gameState?.isHost) { + setLobbySettings({ + type: 'setOne', + action: ['haunting', checked], + }); + } + }} + control={} + /> + + + { + setSettings({ + type: 'setLobbySetting', + action: ['hearImpostorsInVents', checked], + }); + if (gameState?.isHost) { + setLobbySettings({ + type: 'setOne', + action: ['hearImpostorsInVents', checked], + }); + } + }} + control={} + /> + + + { + setSettings({ + type: 'setLobbySetting', + action: ['commsSabotage', checked], + }); + if (gameState?.isHost) { + setLobbySettings({ + type: 'setOne', + action: ['commsSabotage', checked], + }); + } + }} + control={} + /> +
Audio @@ -700,6 +815,45 @@ const Settings: React.FC = function ({ + Overlay + { + setSettings({ + type: 'setOne', + action: ['overlayPosition', ev.target.value], + }); + }} + > + {(storeConfig.schema?.overlayPosition?.enum as string[]).map( + (position) => ( + + ) + )} + + { + setSettings({ + type: 'setOne', + action: ['meetingOverlay', checked], + }); + }} + control={} + /> + Advanced