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/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 155cf322..aac57a8b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,7 +1,7 @@ --- name: Bug report about: Create a report to help us improve -title: '[BUG REPORT]' +title: '' labels: 'bug' assignees: '' --- diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index fef99b67..eff3df63 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,7 +1,7 @@ --- name: Feature request about: Suggest an idea for this project -title: '[FEATURE]' +title: '' labels: 'enhancement' assignees: '' --- diff --git a/TODO.md b/TODO.md index af4086ed..d879ee43 100644 --- a/TODO.md +++ b/TODO.md @@ -6,7 +6,7 @@ - [x] Move the default server to a better host. - [x] Rewrite all error messages to be even more human-readable. - [ ] Integrate an official server list into the client. -- [ ] Detect the reason *why* the server can't provide offsets: i.e. Among Us just updated, it's an old version of Among Us, the server hasn't updated, etc. +- [x] Detect the reason *why* the server can't provide offsets: i.e. Among Us just updated, it's an old version of Among Us, the server hasn't updated, etc. ### Stretch @@ -18,10 +18,10 @@ - [ ] Add a microphone boost slider. - [ ] Add a speaker adjustment slider. - [ ] Add individual adjustment sliders to each of the players. -- [ ] Handle all RTC errors to make it unnecessary to ever re-open an RTC connection. +- [x] Handle all RTC errors to make it unnecessary to ever re-open an RTC connection. - [ ] Detect reason for RTC failure: NAT type, etc? -- [ ] Re-enable all `navigator.getUserMedia` functions that can be re-enabled with autoGainControl kicking in. -- [ ] Move all player-to-player communication logic to RTC data channels, versus sending them over the websocket. +- [x] Re-enable all `navigator.getUserMedia` functions that can be re-enabled with autoGainControl kicking in. +- [x] Move all player-to-player communication logic to RTC data channels, versus sending them over the websocket. ### Stretch @@ -29,9 +29,9 @@ ## Game Reader -- [ ] Fix unicode characters in player names +- [x] Fix unicode characters in player names - [ ] Indicate to the user when it can't read memory properly. Example: screen displays `MENU` while in lobby due to some misplaced offset. -- [ ] Don't use the Unity Analytics file to read the game version. Use either a hash of the GameAssembly dll, or DMA it from the process. +- [x] Don't use the Unity Analytics file to read the game version. Use either a hash of the GameAssembly dll, or DMA it from the process. ### Stretch diff --git a/package.json b/package.json index 46a55260..99566e80 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "crewlink", - "version": "1.1.6", + "version": "2.0.1", "license": "GPL-3.0-or-later", "description": "Free, open, Among Us proximity voice chat", "repository": { @@ -45,11 +45,13 @@ "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": "^0.7.1", "memoryjs": "git://github.com/Rob--/memoryjs", + "pretty-bytes": "^5.5.0", "react": "^17.0.1", "react-dom": "^17.0.1", "registry-js": "^1.12.0", @@ -68,6 +70,7 @@ "@types/deep-equal": "^1.0.1", "@types/js-yaml": "^3.12.5", "@types/node": "12", + "@types/pretty-bytes": "^5.2.0", "@types/react": "^16.9.53", "@types/react-dom": "^16.9.8", "@types/simple-peer": "^9.6.1", @@ -76,6 +79,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 new file mode 100644 index 00000000..2070237f --- /dev/null +++ b/src/common/ipc-messages.ts @@ -0,0 +1,44 @@ +import { ProgressInfo } from 'builder-util-runtime'; + +// Renderer --> Main (send/on) +export enum IpcMessages { + SHOW_ERROR_DIALOG = 'SHOW_ERROR_DIALOG', + 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) +export enum IpcSyncMessages { + GET_INITIAL_STATE = 'GET_INITIAL_STATE', +} + +// Renderer --> Main (invoke/handle) +export enum IpcHandlerMessages { + START_HOOK = 'START_HOOK', +} + +// Main --> Renderer (send/on) +export enum IpcRendererMessages { + NOTIFY_GAME_OPENED = 'NOTIFY_GAME_OPENED', + NOTIFY_GAME_STATE_CHANGED = 'NOTIFY_GAME_STATE_CHANGED', + TOGGLE_DEAFEN = 'TOGGLE_DEAFEN', + TOGGLE_MUTE = 'TOGGLE_MUTE', + PUSH_TO_TALK = 'PUSH_TO_TALK', + ERROR = 'ERROR', + AUTO_UPDATER_STATE = 'AUTO_UPDATER_STATE', +} + +export interface AutoUpdaterState { + state: 'error' | 'available' | 'downloading' | 'downloaded' | 'unavailable'; + error?: string; + progress?: ProgressInfo; +} diff --git a/src/main/GameReader.ts b/src/main/GameReader.ts index 87e0d690..3638a6ab 100644 --- a/src/main/GameReader.ts +++ b/src/main/GameReader.ts @@ -7,12 +7,17 @@ import { ProcessObject, readBuffer, readMemory as readMemoryRaw, + findPattern as findPatternRaw, } from 'memoryjs'; import Struct from 'structron'; -import { GameState, AmongUsState, Player } from '../common/AmongUsState'; +import { IpcRendererMessages } from '../common/ipc-messages'; +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'; @@ -36,7 +41,7 @@ interface PlayerReport { } export default class GameReader { - reply: (event: string, ...args: unknown[]) => void; + sendIPC: Electron.WebContents['send']; offsets: IOffsets | undefined; PlayerStruct: Struct | undefined; @@ -44,12 +49,11 @@ 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'; @@ -58,18 +62,14 @@ export default class GameReader { (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.reply('gameOpen', true); + this.initializeoffsets(); + this.sendIPC(IpcRendererMessages.NOTIFY_GAME_OPENED, true); } catch (e) { if (processOpen && e.toString() === 'Error: unable to find process') throw Errors.OPEN_AS_ADMINISTRATOR; @@ -77,8 +77,7 @@ export default class GameReader { } } else if (this.amongUs && !processOpen) { this.amongUs = null; - this.dllHash = null; - this.reply('gameOpen', false); + this.sendIPC(IpcRendererMessages.NOTIFY_GAME_OPENED, false); } return; } @@ -89,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) { @@ -157,62 +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; - 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 ( @@ -239,97 +302,123 @@ 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) { try { - this.reply('gameState', newState); + this.sendIPC(IpcRendererMessages.NOTIFY_GAME_STATE_CHANGED, newState); } catch (e) { process.exit(0); } } this.lastState = newState; this.oldGameState = state; - return null; // No error } return null; } - constructor(reply: (event: string, ...args: unknown[]) => void) { - this.reply = reply; + constructor(sendIPC: Electron.WebContents['send']) { + 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; @@ -337,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', @@ -374,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, @@ -388,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 26d5d802..b4d6f4e1 100644 --- a/src/main/hook.ts +++ b/src/main/hook.ts @@ -1,12 +1,13 @@ -import { app, dialog, ipcMain } from 'electron'; -import path from 'path'; -// import * as Struct from 'structron'; -import { HKEY, enumerateValues } from 'registry-js'; -import spawn from 'cross-spawn'; +import { ipcMain } from 'electron'; import GameReader from './GameReader'; import iohook from 'iohook'; import Store from 'electron-store'; import { ISettings } from '../common/ISettings'; +import { + IpcHandlerMessages, + IpcRendererMessages, + IpcSyncMessages, +} from '../common/ipc-messages'; interface IOHookEvent { type: string; @@ -24,7 +25,18 @@ const store = new Store(); let readingGame = false; let gameReader: GameReader; -ipcMain.on('start', async (event) => { +ipcMain.on(IpcSyncMessages.GET_INITIAL_STATE, (event) => { + if (!readingGame) { + console.error( + 'Recieved GET_INITIAL_STATE message before the START_HOOK message was received' + ); + event.returnValue = null; + return; + } + event.returnValue = gameReader.lastState; +}); + +ipcMain.handle(IpcHandlerMessages.START_HOOK, async (event) => { if (!readingGame) { readingGame = true; @@ -32,22 +44,30 @@ ipcMain.on('start', async (event) => { iohook.on('keydown', (ev: IOHookEvent) => { const shortcutKey = store.get('pushToTalkShortcut'); if (!isMouseButton(shortcutKey) && keyCodeMatches(shortcutKey as K, ev)) { - event.reply('pushToTalk', 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.reply('pushToTalk', false); + try { + event.sender.send(IpcRendererMessages.PUSH_TO_TALK, false); + } catch (_) {} } if ( !isMouseButton(store.get('deafenShortcut')) && keyCodeMatches(store.get('deafenShortcut') as K, ev) ) { - event.reply('toggleDeafen'); + try { + event.sender.send(IpcRendererMessages.TOGGLE_DEAFEN); + } catch (_) {} } if (keyCodeMatches(store.get('muteShortcut', 'RAlt') as K, ev)) { - event.reply('toggleMute'); + try { + event.sender.send(IpcRendererMessages.TOGGLE_MUTE); + } catch (_) {} } }); @@ -58,7 +78,9 @@ ipcMain.on('start', async (event) => { isMouseButton(shortcutMouse) && mouseClickMatches(shortcutMouse as M, ev) ) { - event.reply('pushToTalk', true); + try { + event.sender.send(IpcRendererMessages.PUSH_TO_TALK, true); + } catch (_) {} } }); iohook.on('mouseup', (ev: IOHookEvent) => { @@ -67,25 +89,38 @@ ipcMain.on('start', async (event) => { isMouseButton(shortcutMouse) && mouseClickMatches(shortcutMouse as M, ev) ) { - event.reply('pushToTalk', 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 (_) {} } }); iohook.start(); // Read game memory - gameReader = new GameReader( - event.reply as (event: string, ...args: unknown[]) => void - ); + gameReader = new GameReader(event.sender.send.bind(event.sender)); - ipcMain.on('initState', (event: Electron.IpcMainEvent) => { - event.returnValue = gameReader.lastState; - }); const frame = () => { const err = gameReader.loop(); if (err) { readingGame = false; - event.reply('error', err); + event.sender.send(IpcRendererMessages.ERROR, err); } else { setTimeout(frame, 1000 / 20); } @@ -94,7 +129,6 @@ ipcMain.on('start', async (event) => { } else if (gameReader) { gameReader.amongUs = null; } - event.reply('started'); }); const keycodeMap = { @@ -134,7 +168,7 @@ type K = keyof typeof keycodeMap; function keyCodeMatches(key: K, ev: IOHookEvent): boolean { if (keycodeMap[key]) return keycodeMap[key] === ev.keycode; - else if (key.length === 1) return key.charCodeAt(0) === ev.rawcode; + else if (key && key.length === 1) return key.charCodeAt(0) === ev.rawcode; else { console.error('Invalid key', key); return false; @@ -158,32 +192,3 @@ function mouseClickMatches(key: M, ev: IOHookEvent): boolean { function isMouseButton(shortcutKey: string): boolean { return shortcutKey.includes('MouseButton'); } - -ipcMain.on('openGame', () => { - // Get steam path from registry - const steamPath = enumerateValues( - HKEY.HKEY_LOCAL_MACHINE, - 'SOFTWARE\\WOW6432Node\\Valve\\Steam' - ).find((v) => v.name === 'InstallPath'); - // Check if Steam is installed - if (!steamPath) { - dialog.showErrorBox('Error', 'Could not find your Steam install path.'); - } else { - try { - const process = spawn(path.join(steamPath.data as string, 'steam.exe'), [ - '-applaunch', - '945360', - ]); - process.on('error', () => { - dialog.showErrorBox('Error', 'Please launch the game manually.'); - }); - } catch (e) { - dialog.showErrorBox('Error', 'Please launch the game manually.'); - } - } -}); - -ipcMain.on('relaunch', () => { - app.relaunch(); - app.quit(); -}); diff --git a/src/main/index.ts b/src/main/index.ts index adf3a586..01acc139 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -6,11 +6,16 @@ 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; app.commandLine.appendSwitch('disable-pinch'); @@ -34,36 +39,50 @@ function createMainWindow() { transparent: true, webPreferences: { nodeIntegration: true, - enableRemoteModule: true, webSecurity: false, }, }); mainWindowState.manage(window); - if (isDevelopment) { - window.webContents.openDevTools(); + // Force devtools into detached mode otherwise they are unusable + window.webContents.openDevTools({ + mode: 'detach', + }); } + let crewlinkVersion: string; if (isDevelopment) { + crewlinkVersion = '0.0.0'; window.loadURL( - `http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}?version=${autoUpdater.currentVersion.version}` + `http://localhost:${process.env.ELECTRON_WEBPACK_WDS_PORT}?version=DEV&view=app` ); } else { + crewlinkVersion = autoUpdater.currentVersion.version; window.loadURL( formatUrl({ pathname: joinPath(__dirname, 'index.html'), protocol: 'file', query: { version: autoUpdater.currentVersion.version, + view: 'app', }, slashes: true, }) ); } + window.webContents.userAgent = `CrewLink/${crewlinkVersion} (${process.platform})`; window.on('closed', () => { mainWindow = null; + if (overlayWindow != null) { + try { + overlayWindow.close(); + } catch (_) { + console.error(_); + } + overlayWindow = null; + } }); window.webContents.on('devtools-opened', () => { @@ -76,11 +95,104 @@ 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); + 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(); } else { - autoUpdater.checkForUpdatesAndNotify(); + autoUpdater.checkForUpdates(); + autoUpdater.on('update-available', () => { + mainWindow?.webContents.send(IpcRendererMessages.AUTO_UPDATER_STATE, { + state: 'available', + }); + }); + autoUpdater.on('error', (err: string) => { + mainWindow?.webContents.send(IpcRendererMessages.AUTO_UPDATER_STATE, { + state: 'error', + error: err, + }); + }); + autoUpdater.on('download-progress', (progress: ProgressInfo) => { + mainWindow?.webContents.send(IpcRendererMessages.AUTO_UPDATER_STATE, { + state: 'downloading', + progress, + }); + }); + autoUpdater.on('update-downloaded', () => { + mainWindow?.webContents.send(IpcRendererMessages.AUTO_UPDATER_STATE, { + state: 'downloaded', + }); + app.relaunch(); + autoUpdater.quitAndInstall(); + }); + + // Mock auto-update download + // setTimeout(() => { + // mainWindow?.webContents.send(IpcRendererMessages.AUTO_UPDATER_STATE, { + // state: 'available' + // }); + // let total = 1000*1000; + // let i = 0; + // let interval = setInterval(() => { + // mainWindow?.webContents.send(IpcRendererMessages.AUTO_UPDATER_STATE, { + // state: 'downloading', + // progress: { + // total, + // delta: total * 0.01, + // transferred: i * total / 100, + // percent: i, + // bytesPerSecond: 1000 + // } + // } as AutoUpdaterState); + // i++; + // if (i === 100) { + // clearInterval(interval); + // mainWindow?.webContents.send(IpcRendererMessages.AUTO_UPDATER_STATE, { + // state: 'downloaded', + // }); + // } + // }, 100); + // }, 10000); + app.on('second-instance', () => { // Someone tried to run a second instance, we should focus our window. if (mainWindow) { @@ -93,10 +205,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) { @@ -105,7 +225,10 @@ if (!gotTheLock) { }); // create main BrowserWindow when electron is ready - app.on('ready', () => { + app.whenReady().then(() => { mainWindow = createMainWindow(); + overlayWindow = createOverlay(); + initializeIpcListeners(overlayWindow); + initializeIpcHandlers(); }); } diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts new file mode 100644 index 00000000..12973910 --- /dev/null +++ b/src/main/ipc-handlers.ts @@ -0,0 +1,73 @@ +import { app, BrowserWindow, dialog, ipcMain } from 'electron'; +import { HKEY, enumerateValues } from 'registry-js'; +import spawn from 'cross-spawn'; +import path from 'path'; + +import { IpcMessages, IpcOverlayMessages } from '../common/ipc-messages'; + +// Listeners are fire and forget, they do not have "responses" or return values +export const initializeIpcListeners = (overlayWindow: BrowserWindow): void => { + ipcMain.on( + IpcMessages.SHOW_ERROR_DIALOG, + (e, opts: { title: string; content: string }) => { + if ( + typeof opts === 'object' && + opts && + typeof opts.title === 'string' && + typeof opts.content === 'string' + ) { + dialog.showErrorBox(opts.title, opts.content); + } + } + ); + + ipcMain.on(IpcMessages.OPEN_AMONG_US_GAME, () => { + // Get steam path from registry + const steamPath = enumerateValues( + HKEY.HKEY_LOCAL_MACHINE, + 'SOFTWARE\\WOW6432Node\\Valve\\Steam' + ).find((v) => v.name === 'InstallPath'); + // Check if Steam is installed + if (!steamPath) { + dialog.showErrorBox('Error', 'Could not find your Steam install path.'); + } else { + try { + const process = spawn( + path.join(steamPath.data as string, 'steam.exe'), + ['-applaunch', '945360'] + ); + process.on('error', () => { + dialog.showErrorBox('Error', 'Please launch the game through Steam.'); + }); + } catch (e) { + dialog.showErrorBox('Error', 'Please launch the game through Steam.'); + } + } + }); + + ipcMain.on(IpcMessages.RESTART_CREWLINK, () => { + app.relaunch(); + app.quit(); + }); + + ipcMain.on(IpcMessages.QUIT_CREWLINK, () => { + for (const win of BrowserWindow.getAllWindows()) { + win.close(); + } + 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 +// or the caller should be "await"'ing them. If neither of these are the case +// consider making it a "listener" instead for performance and readability +export const initializeIpcHandlers = (): void => { + // TODO: Put handlers here +}; 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 f4addb87..dfa2c2b0 100644 --- a/src/main/offsetStore.ts +++ b/src/main/offsetStore.ts @@ -1,134 +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 { - '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], + 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, + }, + }, + }, + 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 9de299ab..a6903375 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -1,5 +1,7 @@ import React, { Dispatch, + ErrorInfo, + ReactChild, SetStateAction, useEffect, useReducer, @@ -8,7 +10,7 @@ import React, { import ReactDOM from 'react-dom'; import Voice from './Voice'; import Menu from './Menu'; -import { ipcRenderer, remote } from 'electron'; +import { ipcRenderer } from 'electron'; import { AmongUsState } from '../common/AmongUsState'; import Settings, { settingsReducer, @@ -20,11 +22,30 @@ import { LobbySettingsContext, } from './contexts'; import { ThemeProvider } from '@material-ui/core/styles'; +import { + AutoUpdaterState, + IpcHandlerMessages, + IpcMessages, + IpcOverlayMessages, + IpcRendererMessages, + IpcSyncMessages, +} from '../common/ipc-messages'; import theme from './theme'; import SettingsIcon from '@material-ui/icons/Settings'; import CloseIcon from '@material-ui/icons/Close'; import IconButton from '@material-ui/core/IconButton'; +import Dialog from '@material-ui/core/Dialog'; import makeStyles from '@material-ui/core/styles/makeStyles'; +import LinearProgress from '@material-ui/core/LinearProgress'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import DialogContent from '@material-ui/core/DialogContent'; +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) { @@ -83,7 +104,7 @@ const TitleBar: React.FC = function ({ className={classes.button} style={{ right: 0 }} size="small" - onClick={() => remote.getCurrentWindow().close()} + onClick={() => ipcRenderer.send(IpcMessages.QUIT_CREWLINK)} > @@ -96,11 +117,74 @@ 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); const [error, setError] = useState(''); + const [updaterState, setUpdaterState] = useState({ + state: 'unavailable', + }); const settings = useReducer(settingsReducer, { alwaysOnTop: false, microphone: 'Default', @@ -112,11 +196,19 @@ 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(lobbySettingsReducer, settings[0].localLobbySettings); + const lobbySettings = useReducer( + lobbySettingsReducer, + settings[0].localLobbySettings + ); useEffect(() => { const onOpen = (_: Electron.IpcRendererEvent, isOpen: boolean) => { @@ -125,24 +217,65 @@ function App() { const onState = (_: Electron.IpcRendererEvent, newState: AmongUsState) => { setGameState(newState); }; - let shouldInit = true; const onError = (_: Electron.IpcRendererEvent, error: string) => { shouldInit = false; setError(error); }; - ipcRenderer.on('gameOpen', onOpen); - ipcRenderer.on('error', onError); - ipcRenderer.on('gameState', onState); - ipcRenderer.once('started', () => { - if (shouldInit) setGameState(ipcRenderer.sendSync('initState')); - }); + const onAutoUpdaterStateChange = ( + _: Electron.IpcRendererEvent, + state: AutoUpdaterState + ) => { + setUpdaterState((old) => ({ ...old, ...state })); + }; + let shouldInit = true; + ipcRenderer + .invoke(IpcHandlerMessages.START_HOOK) + .then(() => { + if (shouldInit) { + setGameState(ipcRenderer.sendSync(IpcSyncMessages.GET_INITIAL_STATE)); + } + }) + .catch((error: Error) => { + if (shouldInit) { + shouldInit = false; + setError(error.message); + } + }); + ipcRenderer.on( + IpcRendererMessages.AUTO_UPDATER_STATE, + onAutoUpdaterStateChange + ); + ipcRenderer.on(IpcRendererMessages.NOTIFY_GAME_OPENED, onOpen); + ipcRenderer.on(IpcRendererMessages.NOTIFY_GAME_STATE_CHANGED, onState); + ipcRenderer.on(IpcRendererMessages.ERROR, onError); return () => { - ipcRenderer.off('gameOpen', onOpen); - ipcRenderer.off('error', onError); - ipcRenderer.off('gameState', onState); + ipcRenderer.off( + IpcRendererMessages.AUTO_UPDATER_STATE, + onAutoUpdaterStateChange + ); + ipcRenderer.off(IpcRendererMessages.NOTIFY_GAME_OPENED, onOpen); + ipcRenderer.off(IpcRendererMessages.NOTIFY_GAME_STATE_CHANGED, onState); + ipcRenderer.off(IpcRendererMessages.ERROR, onError); + shouldInit = false; }; }, []); + 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: @@ -152,6 +285,7 @@ function App() { page = ; break; } + return ( @@ -161,16 +295,55 @@ function App() { settingsOpen={settingsOpen} setSettingsOpen={setSettingsOpen} /> - setSettingsOpen(false)} - /> - {page} + + <> + 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' && ( + + + + )} + + {page} + + ); -} +}; ReactDOM.render(, document.getElementById('app')); diff --git a/src/renderer/Avatar.tsx b/src/renderer/Avatar.tsx index f8b052ea..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]; @@ -89,7 +91,12 @@ const Avatar: React.FC = function ({ } break; case 'novoice': - icon = ; + icon = ( + + ); break; case 'disconnected': icon = ; @@ -98,7 +105,7 @@ const Avatar: React.FC = function ({ return ( -
+
({ position: 'absolute', top: '38%', left: '17%', + width: '73.5%', transform: 'scale(0.8)', zIndex: 3, display: ({ isAlive }: UseCanvasStylesParams) => diff --git a/src/renderer/Footer.tsx b/src/renderer/Footer.tsx index 66d1b1c5..82857e99 100644 --- a/src/renderer/Footer.tsx +++ b/src/renderer/Footer.tsx @@ -18,6 +18,9 @@ const useStyles = makeStyles(() => ({ display: 'flex', justifyContent: 'space-evenly', margin: 5, + '&>svg': { + cursor: 'pointer', + }, }, })); diff --git a/src/renderer/Menu.tsx b/src/renderer/Menu.tsx index c2106216..a2833323 100644 --- a/src/renderer/Menu.tsx +++ b/src/renderer/Menu.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { ipcRenderer } from 'electron'; -import './css/menu.css'; import Footer from './Footer'; +import { IpcMessages } from '../common/ipc-messages'; import makeStyles from '@material-ui/core/styles/makeStyles'; import CircularProgress from '@material-ui/core/CircularProgress'; import Typography from '@material-ui/core/Typography'; @@ -16,6 +16,33 @@ const useStyles = makeStyles((theme) => ({ error: { paddingTop: theme.spacing(4), }, + menu: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'start', + }, + waiting: { + fontSize: 20, + marginTop: 12, + marginBottom: 12, + }, + button: { + color: 'white', + background: 'none', + padding: '2px 10px', + borderRadius: 10, + border: '4px solid white', + fontSize: 24, + outline: 'none', + fontWeight: 500, + fontFamily: '"Varela", sans-serif', + marginTop: 24, + '&:hover': { + borderColor: '#00ff00', + cursor: 'pointer', + }, + }, })); export interface MenuProps { @@ -26,7 +53,7 @@ const Menu: React.FC = function ({ error }: MenuProps) { const classes = useStyles(); return (
-
+
{error ? (
@@ -39,12 +66,12 @@ const Menu: React.FC = function ({ error }: MenuProps) {
) : ( <> - Waiting for Among Us + Waiting for Among Us + setOpen(false)}> + Change Voice Server + + + + This option is for advanced users only. Other servers can steal your + info or crash CrewLink. + + + + + + + + + ); }; @@ -312,19 +418,19 @@ interface DisabledTooltipProps { children: ReactChild; } -const DisabledTooltip: React.FC = function ({ disabled, children, title }: DisabledTooltipProps) { +const DisabledTooltip: React.FC = function ({ + disabled, + children, + title, +}: DisabledTooltipProps) { if (disabled) return ( {children} ); - else return ( - <> - {children} - - ); -} + else return <>{children}; +}; const Settings: React.FC = function ({ open, @@ -341,10 +447,9 @@ const Settings: React.FC = function ({ type: 'set', action: store.store, }); - console.log(store.get('localLobbySettings')); setLobbySettings({ type: 'set', - action: store.get('localLobbySettings') + action: store.get('localLobbySettings'), }); }, []); @@ -414,15 +519,20 @@ const Settings: React.FC = function ({ const microphones = devices.filter((d) => d.kind === 'audioinput'); const speakers = devices.filter((d) => d.kind === 'audiooutput'); - const [localDistance, setLocalDistance] = useState(settings.localLobbySettings.maxDistance); + const [localDistance, setLocalDistance] = useState( + settings.localLobbySettings.maxDistance + ); useEffect(() => { setLocalDistance(settings.localLobbySettings.maxDistance); }, [settings.localLobbySettings.maxDistance]); - const isInMenuOrLobby = gameState.gameState === GameState.LOBBY || gameState.gameState === GameState.MENU; - const canChangeLobbySettings = (gameState.gameState === GameState.MENU) || (gameState.isHost && gameState.gameState === GameState.LOBBY); + const isInMenuOrLobby = + gameState?.gameState === GameState.LOBBY || + gameState?.gameState === GameState.MENU; + const canChangeLobbySettings = + gameState?.gameState === GameState.MENU || + (gameState?.isHost && gameState?.gameState === GameState.LOBBY); - console.log(gameState); return (
@@ -445,21 +555,21 @@ const Settings: React.FC = function ({ Settings
- { - setSettings({ - type: 'setOne', - action: ['serverURL', url], - }); - }} - /> - {/* Lobby Settings */}
Lobby Settings - Voice Distance: {canChangeLobbySettings ? localDistance : lobbySettings.maxDistance} - + + Voice Distance:{' '} + {canChangeLobbySettings ? localDistance : lobbySettings.maxDistance} + + = function ({ type: 'setLobbySetting', action: ['maxDistance', newValue as number], }); - if (gameState.isHost) { + if (gameState?.isHost) { setLobbySettings({ type: 'setOne', action: ['maxDistance', newValue as number], @@ -487,8 +597,102 @@ 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 = 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 = function ({ }} control={} /> + { + setSettings({ + type: 'setOne', + action: ['serverURL', url], + }); + }} + className={classes.urlDialog} + />