feat: Replay packets server functionality! (#287)

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Vitaly 2025-02-23 03:48:15 +03:00 committed by GitHub
commit 1387cb036b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 1232 additions and 103 deletions

View file

@ -144,6 +144,7 @@ General:
- **`?setting=<setting_name>:<setting_value>`** - Set and lock the setting on load. You can set multiple settings by separating them with `&` e.g. `?setting=autoParkour:true&setting=renderDistance:4`
- `?modal=<modal>` - Open specific modal on page load eg `keybindings`. Very useful on UI changes testing during dev. For path use `,` as separator. To get currently opened modal type this in the console: `activeModalStack.at(-1).reactType`
- `?replayFileUrl=<url>` - Load and start a packet replay session from a URL with a integrated server. For debugging / previewing recorded sessions. The file must be CORS enabled.
Server specific:

View file

@ -81,7 +81,7 @@
"mojangson": "^2.0.4",
"net-browserify": "github:zardoy/prismarinejs-net-browserify",
"node-gzip": "^1.1.2",
"mcraft-fun-mineflayer": "0.0.3",
"mcraft-fun-mineflayer": "^0.1.3",
"peerjs": "^1.5.0",
"pixelarticons": "^1.8.1",
"pretty-bytes": "^6.1.1",

47
pnpm-lock.yaml generated
View file

@ -135,8 +135,8 @@ importers:
specifier: ^4.17.21
version: 4.17.21
mcraft-fun-mineflayer:
specifier: 0.0.3
version: 0.0.3(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828(encoding@0.1.13))
specifier: ^0.1.3
version: 0.1.3(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828(encoding@0.1.13))
minecraft-data:
specifier: 3.83.1
version: 3.83.1
@ -6251,9 +6251,9 @@ packages:
resolution: {integrity: sha512-JBi9frIACmzmpKL38YudZJpml+tWP3UuCeb8ko5iJRHpmCmmChE+X3xzVEbEYnYBI2dMiO7915/5eYnKUVys3Q==}
engines: {node: '>=18.0.0'}
mcraft-fun-mineflayer@0.0.3:
resolution: {integrity: sha512-IqYXHk5ihQOF9FzEpMWsFwgilTySklYVj3AODK7sdgaSe+pU9wZllJjVvsYdc/F3uLMgogkyTLuVoEVMD+UiSA==}
version: 0.0.3
mcraft-fun-mineflayer@0.1.3:
resolution: {integrity: sha512-3Xds5XBLwYHFgH09RS9fHNK7NJI2ZStXiYvU/9FVUMd9TOwcMwLorn1kNYOw022/0nGi3hibT3ldj6vLCjASIg==}
version: 0.1.3
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
peerDependencies:
'@roamhq/wrtc': '*'
@ -6461,6 +6461,11 @@ packages:
resolution: {tarball: https://codeload.github.com/zardoy/minecraft-inventory-gui/tar.gz/75e940a4cd50d89e0ba03db3733d5d704917a3c8}
version: 1.0.1
minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d:
resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d}
version: 1.54.0
engines: {node: '>=22'}
minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76:
resolution: {tarball: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76}
version: 1.54.0
@ -12775,7 +12780,7 @@ snapshots:
flatmap: 0.0.3
long: 5.2.3
minecraft-data: 3.83.1
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13)
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13)
mkdirp: 2.1.6
node-gzip: 1.1.2
node-rsa: 1.1.1
@ -16690,11 +16695,11 @@ snapshots:
apl-image-packer: 1.1.0
zod: 3.24.1
mcraft-fun-mineflayer@0.0.3(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828(encoding@0.1.13)):
mcraft-fun-mineflayer@0.1.3(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828(encoding@0.1.13)):
dependencies:
'@zardoy/flying-squid': 0.0.49(encoding@0.1.13)
exit-hook: 2.2.1
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13)
minecraft-protocol: https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13)
mineflayer: https://codeload.github.com/zardoy/mineflayer/tar.gz/4fa1af9964cab91315d8d1ae02615f3039638828(encoding@0.1.13)
prismarine-item: 1.16.0
ws: 8.18.0
@ -17003,6 +17008,32 @@ snapshots:
- '@types/react'
- react
minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/5ec3dd4b367fcc039fbcb3edd214fe3cf8178a6d(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13):
dependencies:
'@types/node-rsa': 1.1.4
'@types/readable-stream': 4.0.12
aes-js: 3.1.2
buffer-equal: 1.0.1
debug: 4.4.0(supports-color@8.1.1)
endian-toggle: 0.0.0
lodash.get: 4.4.2
lodash.merge: 4.6.2
minecraft-data: 3.83.1
minecraft-folder-path: 1.2.0
node-fetch: 2.7.0(encoding@0.1.13)
node-rsa: 0.4.2
prismarine-auth: 2.4.2(encoding@0.1.13)
prismarine-chat: 1.10.1
prismarine-nbt: 2.5.0
prismarine-realms: 1.3.2(encoding@0.1.13)
protodef: 1.18.0
readable-stream: 4.5.2
uuid-1345: 1.0.2
yggdrasil: 1.7.0(encoding@0.1.13)
transitivePeerDependencies:
- encoding
- supports-color
minecraft-protocol@https://codeload.github.com/PrismarineJS/node-minecraft-protocol/tar.gz/e9eb551ba30ec2e742c49e6927be6402b413bb76(patch_hash=dkeyukcqlupmk563gwxsmjr3yu)(encoding@0.1.13):
dependencies:
'@types/node-rsa': 1.1.4

View file

@ -39,6 +39,12 @@ export type AppQsParams = {
suggest_save?: string
noPacketsValidation?: string
testCrashApp?: string
// Replay params
replayFilter?: string
replaySpeed?: string
replayFileUrl?: string
replayValidateClient?: string
}
export type AppQsParamsArray = {
@ -55,7 +61,7 @@ type AppQsParamsArrayTransformed = {
export const appQueryParams = new Proxy<AppQsParams>({} as AppQsParams, {
get (target, property) {
if (typeof property !== 'string') {
return null
return undefined
}
return qsParams.get(property)
},

View file

@ -10,7 +10,10 @@ import { fsState, loadSave } from './loadSave'
import { installResourcepackPack, installTexturePackFromHandle, updateTexturePackInstalledState } from './resourcePack'
import { miscUiState } from './globalState'
import { setLoadingScreenStatus } from './appStatus'
const { GoogleDriveFileSystem } = require('google-drive-browserfs/src/backends/GoogleDrive') // disable type checking
import { VALID_REPLAY_EXTENSIONS, openFile } from './packetsReplay/replayPackets'
import { getFixedFilesize } from './downloadAndOpenFile'
import { packetsReplayState } from './react/state/packetsReplayState'
const { GoogleDriveFileSystem } = require('google-drive-browserfs/src/backends/GoogleDrive')
browserfs.install(window)
const defaultMountablePoints = {
@ -621,22 +624,33 @@ export const openFilePicker = (specificCase?: 'resourcepack') => {
if (!picker) {
picker = document.createElement('input')
picker.type = 'file'
picker.accept = '.zip'
picker.accept = specificCase ? '.zip' : [...VALID_REPLAY_EXTENSIONS, '.zip'].join(',')
picker.addEventListener('change', () => {
const file = picker.files?.[0]
picker.value = ''
if (!file) return
if (!file.name.endsWith('.zip')) {
const doContinue = confirm(`Are you sure ${file.name.slice(-20)} is .zip file? Only .zip files are supported. Continue?`)
if (!doContinue) return
}
if (specificCase === 'resourcepack') {
if (!file.name.endsWith('.zip')) {
const doContinue = confirm(`Are you sure ${file.name.slice(-20)} is .zip file? ONLY .zip files are supported. Continue?`)
if (!doContinue) return
}
void installResourcepackPack(file).catch((err) => {
setLoadingScreenStatus(err.message, true)
})
} else {
void openWorldZip(file)
// eslint-disable-next-line no-lonely-if
if (VALID_REPLAY_EXTENSIONS.some(ext => file.name.endsWith(ext)) || file.name.startsWith('packets-replay')) {
void file.text().then(contents => {
openFile({
contents,
filename: file.name,
filesize: file.size
})
})
} else {
void openWorldZip(file)
}
}
})
picker.hidden = true

View file

@ -23,6 +23,9 @@ export type ConnectOptions = {
peerOptions?: any
viewerWsConnect?: string
saveServerToHistory?: boolean
/** Will enable local replay server */
worldStateFileContents?: string
}
export const getVersionAutoSelect = (autoVersionSelect = options.serversAutoVersionSelect) => {

View file

@ -8,7 +8,7 @@ import { CommandEventArgument, SchemaCommandInput } from 'contro-max/build/types
import { stringStartsWith } from 'contro-max/build/stringUtils'
import { UserOverrideCommand, UserOverridesConfig } from 'contro-max/build/types/store'
import { isGameActive, showModal, gameAdditionalState, activeModalStack, hideCurrentModal, miscUiState, hideModal, hideAllModals } from './globalState'
import { goFullscreen, pointerLock, reloadChunks } from './utils'
import { goFullscreen, isInRealGameSession, pointerLock, reloadChunks } from './utils'
import { options } from './optionsStorage'
import { openPlayerInventory } from './inventoryWindows'
import { chatInputValueGlobal } from './react/Chat'
@ -835,7 +835,7 @@ const selectItem = async () => {
addEventListener('mousedown', async (e) => {
if ((e.target as HTMLElement).matches?.('#VRButton')) return
if (gameAdditionalState.viewerConnection && !(e.target as HTMLElement).id.includes('ui-root')) return
if (!isInRealGameSession() && !(e.target as HTMLElement).id.includes('ui-root')) return
void pointerLock.requestPointerLock()
if (!bot) return
// wheel click

View file

@ -3,12 +3,30 @@ import { openWorldFromHttpDir, openWorldZip } from './browserfs'
import { getResourcePackNames, installResourcepackPack, resourcePackState, updateTexturePackInstalledState } from './resourcePack'
import { setLoadingScreenStatus } from './appStatus'
import { appQueryParams, appQueryParamsArray } from './appParams'
import { VALID_REPLAY_EXTENSIONS, openFile } from './packetsReplay/replayPackets'
export const getFixedFilesize = (bytes: number) => {
return prettyBytes(bytes, { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
const inner = async () => {
const { replayFileUrl } = appQueryParams
if (replayFileUrl) {
setLoadingScreenStatus('Downloading replay file...')
const response = await fetch(replayFileUrl)
const contentLength = response.headers?.get('Content-Length')
const size = contentLength ? +contentLength : undefined
const filename = replayFileUrl.split('/').pop()
const contents = await response.text()
openFile({
contents,
filename,
filesize: size
})
return true
}
const mapUrlDir = appQueryParamsArray.mapDir ?? []
const mapUrlDirGuess = appQueryParams.mapDirGuess
const mapUrlDirBaseUrl = appQueryParams.mapDirBaseUrl

View file

@ -6,6 +6,7 @@ import { versions } from 'minecraft-data'
import { openWorldDirectory, openWorldZip } from './browserfs'
import { isGameActive } from './globalState'
import { showNotification } from './react/NotificationProvider'
import { openFile, VALID_REPLAY_EXTENSIONS } from './packetsReplay/replayPackets'
const parseNbt = promisify(nbt.parse)
const simplifyNbt = nbt.simplify
@ -53,10 +54,19 @@ async function handleDroppedFile (file: File) {
alert('Rar files are not supported yet!')
return
}
if (VALID_REPLAY_EXTENSIONS.some(ext => file.name.endsWith(ext)) || file.name.startsWith('packets-replay')) {
const contents = await file.text()
openFile({
contents,
filename: file.name,
filesize: file.size
})
return
}
if (file.name.endsWith('.mca')) {
const tempPath = '/data/temp.mca'
try {
await fs.promises.writeFile(tempPath, Buffer.from(await file.arrayBuffer()))
await fs.promises.writeFile(tempPath, Buffer.from(await file.arrayBuffer()) as any)
const region = new RegionFile(tempPath)
await region.initialize()
const chunks: Record<string, any> = {}

View file

@ -45,7 +45,7 @@ import * as THREE from 'three'
import MinecraftData from 'minecraft-data'
import debug from 'debug'
import { defaultsDeep } from 'lodash-es'
import initializePacketsReplay from './packetsReplay'
import initializePacketsReplay from './packetsReplay/packetsReplayLegacy'
import { initVR } from './vr'
import {
@ -111,6 +111,9 @@ import { states } from 'minecraft-protocol'
import { initMotionTracking } from './react/uiMotion'
import { UserError } from './mineflayer/userError'
import ping from './mineflayer/plugins/ping'
import { LocalServer } from './customServer'
import { startLocalReplayServer } from './packetsReplay/replayPackets'
import { localRelayServerPlugin } from './mineflayer/plugins/localRelay'
window.debug = debug
window.THREE = THREE
@ -397,6 +400,7 @@ export async function connect (connectOptions: ConnectOptions) {
const renderDistance = singleplayer ? renderDistanceSingleplayer : multiplayerRenderDistance
let updateDataAfterJoin = () => { }
let localServer
let localReplaySession: ReturnType<typeof startLocalReplayServer> | undefined
try {
const serverOptions = defaultsDeep({}, connectOptions.serverOverrides ?? {}, options.localServerOptions, defaultServerOptions)
Object.assign(serverOptions, connectOptions.serverOverridesFlat ?? {})
@ -436,6 +440,16 @@ export async function connect (connectOptions: ConnectOptions) {
let finalVersion = connectOptions.botVersion || (singleplayer ? serverOptions.version : undefined)
if (connectOptions.worldStateFileContents) {
try {
localReplaySession = startLocalReplayServer(connectOptions.worldStateFileContents)
} catch (err) {
console.error(err)
throw new UserError(`Failed to start local replay server: ${err}`)
}
finalVersion = localReplaySession.version
}
if (singleplayer) {
// SINGLEPLAYER EXPLAINER:
// Note 1: here we have custom sync communication between server Client (flying-squid) and game client (mineflayer)
@ -484,6 +498,8 @@ export async function connect (connectOptions: ConnectOptions) {
initialLoadingText = `Connecting to server ${server.host}:${server.port ?? 25_565} with version ${finalVersion}`
} else if (connectOptions.viewerWsConnect) {
initialLoadingText = `Connecting to Mineflayer WebSocket server ${connectOptions.viewerWsConnect}`
} else if (connectOptions.worldStateFileContents) {
initialLoadingText = `Loading local replay server`
} else {
initialLoadingText = 'We have no idea what to do'
}
@ -543,7 +559,7 @@ export async function connect (connectOptions: ConnectOptions) {
...clientDataStream ? {
stream: clientDataStream as any,
} : {},
...singleplayer || p2pMultiplayer ? {
...singleplayer || p2pMultiplayer || localReplaySession ? {
keepAlive: false,
} : {},
...singleplayer ? {
@ -551,6 +567,10 @@ export async function connect (connectOptions: ConnectOptions) {
connect () { },
Client: CustomChannelClient as any,
} : {},
...localReplaySession ? {
connect () { },
Client: CustomChannelClient as any,
} : {},
onMsaCode (data) {
signInMessageState.code = data.user_code
signInMessageState.link = data.verification_uri
@ -613,15 +633,17 @@ export async function connect (connectOptions: ConnectOptions) {
void handleCustomChannel()
}
customEvents.emit('mineflayerBotCreated')
if (singleplayer || p2pMultiplayer) {
// in case of p2pMultiplayer there is still flying-squid on the host side
const _supportFeature = bot.supportFeature
bot.supportFeature = ((feature) => {
if (unsupportedLocalServerFeatures.includes(feature)) {
return false
}
return _supportFeature(feature)
}) as typeof bot.supportFeature
if (singleplayer || p2pMultiplayer || localReplaySession) {
if (singleplayer || p2pMultiplayer) {
// in case of p2pMultiplayer there is still flying-squid on the host side
const _supportFeature = bot.supportFeature
bot.supportFeature = ((feature) => {
if (unsupportedLocalServerFeatures.includes(feature)) {
return false
}
return _supportFeature(feature)
}) as typeof bot.supportFeature
}
bot.emit('inject_allowed')
bot._client.emit('connect')
@ -670,6 +692,9 @@ export async function connect (connectOptions: ConnectOptions) {
if (connectOptions.server) {
bot.loadPlugin(ping)
}
if (!localReplaySession) {
bot.loadPlugin(localRelayServerPlugin)
}
if (!bot) return
const p2pConnectTimeout = p2pMultiplayer ? setTimeout(() => { throw new UserError('Spawn timeout. There might be error on the other side, check console.') }, 20_000) : undefined

View file

@ -0,0 +1,32 @@
import { viewerConnector } from 'mcraft-fun-mineflayer'
import { PACKETS_REPLAY_FILE_EXTENSION, WORLD_STATE_FILE_EXTENSION } from 'mcraft-fun-mineflayer/build/worldState'
import { Bot } from 'mineflayer'
export const localRelayServerPlugin = (bot: Bot) => {
bot.loadPlugin(
viewerConnector({
tcpEnabled: false,
websocketEnabled: false,
})
)
bot.downloadCurrentWorldState = () => {
const worldState = bot.webViewer._unstable.createStateCaptureFile()
const a = document.createElement('a')
const textContents = worldState.contents
const blob = new Blob([textContents], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
a.href = url
// add readable timestamp to filename
const timestamp = new Date().toISOString().replaceAll(/[-:Z]/g, '')
a.download = `${bot.username}-world-state-${timestamp}.${WORLD_STATE_FILE_EXTENSION}`
a.click()
URL.revokeObjectURL(url)
}
}
declare module 'mineflayer' {
interface Bot {
downloadCurrentWorldState: () => void
}
}

View file

@ -11,7 +11,7 @@ import { getScreenRefreshRate } from './utils'
import { setLoadingScreenStatus } from './appStatus'
import { openFilePicker, resetLocalStorageWithoutWorld } from './browserfs'
import { completeTexturePackInstall, getResourcePackNames, resourcePackState, uninstallResourcePack } from './resourcePack'
import { downloadPacketsReplay, packetsReplaceSessionState } from './packetsReplay'
import { downloadPacketsReplay, packetsReplaceSessionState } from './packetsReplay/packetsReplayLegacy'
import { showOptionsModal } from './react/SelectOption'
import supportedVersions from './supportedVersions.mjs'
import { getVersionAutoSelect } from './connect'

View file

@ -1,6 +1,6 @@
import { proxy } from 'valtio'
import { PacketsLogger } from './packetsReplayBase'
import { options } from './optionsStorage'
import { PacketsLogger } from 'mcraft-fun-mineflayer/build/packetsLogger'
import { options } from '../optionsStorage'
export const packetsReplaceSessionState = proxy({
active: options.packetsReplayAutoStart,

View file

@ -0,0 +1,274 @@
/* eslint-disable no-await-in-loop */
import { createServer, ServerClient } from 'minecraft-protocol'
import { parseReplayContents } from 'mcraft-fun-mineflayer/build/packetsLogger'
import { WorldStateHeader, PACKETS_REPLAY_FILE_EXTENSION, WORLD_STATE_FILE_EXTENSION } from 'mcraft-fun-mineflayer/build/worldState'
import { LocalServer } from '../customServer'
import { UserError } from '../mineflayer/userError'
import { packetsReplayState } from '../react/state/packetsReplayState'
import { getFixedFilesize } from '../react/simpleUtils'
import { appQueryParams } from '../appParams'
const SUPPORTED_FORMAT_VERSION = 1
type ReplayDefinition = {
minecraftVersion: string
replayAgainst?: 'client' | 'server'
serverIp?: string
}
interface OpenFileOptions {
contents: string
filename?: string
filesize?: number
}
export function openFile ({ contents, filename = 'unnamed', filesize }: OpenFileOptions) {
packetsReplayState.replayName = `${filename} (${getFixedFilesize(filesize ?? contents.length)})`
packetsReplayState.isOpen = true
packetsReplayState.isPlaying = false
const connectOptions = {
worldStateFileContents: contents,
username: 'replay'
}
dispatchEvent(new CustomEvent('connect', { detail: connectOptions }))
}
export const startLocalReplayServer = (contents: string) => {
const lines = contents.split('\n')
if (!lines[0]) {
throw new UserError('No header line found. Cannot parse replay definition.')
}
let def: WorldStateHeader | ReplayDefinition
try {
def = JSON.parse(lines[0])
} catch (err) {
throw new UserError(`Invalid JSON in file header: ${String(err)}`)
}
const packetsRaw = lines.slice(1).join('\n')
const replayData = parseReplayContents(packetsRaw)
packetsReplayState.packetsPlayback = []
packetsReplayState.isOpen = true
packetsReplayState.isPlaying = true
packetsReplayState.progress = {
current: 0,
total: replayData.packets.filter(packet => packet.isFromServer).length
}
packetsReplayState.speed = 1
packetsReplayState.replayName ||= `local ${getFixedFilesize(contents.length)}`
packetsReplayState.replayName = `${def.minecraftVersion} ${packetsReplayState.replayName}`
if ('formatVersion' in def && def.formatVersion !== SUPPORTED_FORMAT_VERSION) {
throw new UserError(`Unsupported format version: ${def.formatVersion}`)
}
if ('replayAgainst' in def && def.replayAgainst === 'server') {
throw new Error('not supported')
}
const server = createServer({
Server: LocalServer as any,
version: def.minecraftVersion,
'online-mode': false
})
server.on('login', async client => {
await mainPacketsReplayer(
client,
replayData,
appQueryParams.replayValidateClient === 'true' ? true : undefined
)
})
return {
server,
version: def.minecraftVersion
}
}
// time based packets
const FLATTEN_CLIENT_PACKETS = new Set(['position', 'position_look'])
const positions = {
client: 0,
server: 0
}
const addPacketToReplayer = (name: string, data, isFromClient: boolean, wasUpcoming = false) => {
const side = isFromClient ? 'client' : 'server'
if (wasUpcoming) {
const lastUpcoming = packetsReplayState.packetsPlayback.findLast(p => p.isUpcoming && p.name === name)
if (lastUpcoming) {
lastUpcoming.isUpcoming = false
}
} else {
packetsReplayState.packetsPlayback.push({
name,
data,
isFromClient,
position: ++positions[side]!,
isUpcoming: false,
timestamp: Date.now()
})
}
if (!isFromClient && !wasUpcoming) {
packetsReplayState.progress.current++
}
}
const IGNORE_SERVER_PACKETS = new Set([
'kick_disconnect',
])
const mainPacketsReplayer = async (client: ServerClient, replayData: ReturnType<typeof parseReplayContents>, ignoreClientPacketsWait: string[] | true = []) => {
const writePacket = (name: string, data: any) => {
data = restoreData(data)
client.write(name, data)
}
const playPackets = replayData.packets.filter(p => p.state === 'play')
let clientPackets = [] as Array<{ name: string, params: any }>
const clientsPacketsWaiter = createPacketsWaiter({
unexpectedPacketReceived (name, params) {
console.log('unexpectedPacketReceived', name, params)
addPacketToReplayer(name, params, true)
},
expectedPacketReceived (name, params) {
console.log('expectedPacketReceived', name, params)
addPacketToReplayer(name, params, true, true)
}
})
bot._client.on('writePacket' as any, (name, params) => {
console.log('writePacket', name, params)
clientsPacketsWaiter.addPacket(name, params)
})
console.log('start replaying!')
for (const [i, packet] of playPackets.entries()) {
if (packet.isFromServer) {
writePacket(packet.name, packet.params)
addPacketToReplayer(packet.name, packet.params, false)
await new Promise(resolve => {
setTimeout(resolve, packet.diff * packetsReplayState.speed)
})
} else if (ignoreClientPacketsWait !== true && !ignoreClientPacketsWait.includes(packet.name)) {
clientPackets.push({ name: packet.name, params: packet.params })
if (playPackets[i + 1]?.isFromServer) {
// eslint-disable-next-line @typescript-eslint/no-loop-func
clientPackets = clientPackets.filter((p, index) => {
return !FLATTEN_CLIENT_PACKETS.has(p.name) || index === clientPackets.findIndex(clientPacket => clientPacket.name === p.name)
})
for (const packet of clientPackets) {
packetsReplayState.packetsPlayback.push({
name: packet.name,
data: packet.params,
isFromClient: true,
position: positions.client++,
timestamp: Date.now(),
isUpcoming: true,
})
}
await clientsPacketsWaiter.waitForPackets(clientPackets.map(p => p.name))
clientPackets = []
}
}
}
}
interface PacketsWaiterOptions {
unexpectedPacketReceived?: (name: string, params: any) => void
expectedPacketReceived?: (name: string, params: any) => void
}
interface PacketsWaiter {
addPacket(name: string, params: any): void
waitForPackets(packets: string[]): Promise<void>
}
const createPacketsWaiter = (options: PacketsWaiterOptions = {}): PacketsWaiter => {
let packetHandler: ((data: any, name: string) => void) | null = null
const queuedPackets: Array<{ name: string, params: any }> = []
let isWaiting = false
const handlePacket = (data: any, name: string, waitingPackets: string[], resolve: () => void) => {
if (waitingPackets.includes(name)) {
waitingPackets.splice(waitingPackets.indexOf(name), 1)
options.expectedPacketReceived?.(name, data)
} else {
options.unexpectedPacketReceived?.(name, data)
}
if (waitingPackets.length === 0) {
resolve()
}
}
return {
addPacket (name: string, params: any) {
if (packetHandler) {
packetHandler(params, name)
} else {
queuedPackets.push({ name, params })
}
},
async waitForPackets (packets: string[]) {
if (isWaiting) {
throw new Error('Already waiting for packets')
}
isWaiting = true
try {
await new Promise<void>(resolve => {
const waitingPackets = [...packets]
packetHandler = (data: any, name: string) => {
handlePacket(data, name, waitingPackets, resolve)
}
// Process any queued packets
for (const packet of queuedPackets) {
handlePacket(packet.params, packet.name, waitingPackets, resolve)
}
queuedPackets.length = 0
})
} finally {
isWaiting = false
packetHandler = null
}
}
}
}
const isArrayEqual = (a: any[], b: any[]) => {
if (a.length !== b.length) return false
for (const [i, element] of a.entries()) {
if (element !== b[i]) return false
}
return true
}
const restoreData = (json: any) => {
const keys = Object.keys(json)
if (isArrayEqual(keys.sort(), ['data', 'type'].sort())) {
if (json.type === 'Buffer') {
return Buffer.from(json.data)
}
}
if (typeof json === 'object' && json) {
for (const [key, value] of Object.entries(json)) {
if (typeof value === 'object') {
json[key] = restoreData(value)
}
}
}
return json
}
export const VALID_REPLAY_EXTENSIONS = [`.${PACKETS_REPLAY_FILE_EXTENSION}`, `.${WORLD_STATE_FILE_EXTENSION}`]

View file

@ -1,60 +0,0 @@
export class PacketsLogger {
lastPacketTime = -1
contents = ''
logOnly = [] as string[]
skip = [] as string[]
logStr (str: string) {
this.contents += `${str}\n`
}
log (isFromServer: boolean, packet: { name; state }, data: any) {
if (this.logOnly.length > 0 && !this.logOnly.includes(packet.name)) {
return
}
if (this.skip.length > 0 && this.skip.includes(packet.name)) {
return
}
if (this.lastPacketTime === -1) {
this.lastPacketTime = Date.now()
}
const diff = `+${Date.now() - this.lastPacketTime}`
// serialize bigint
const str = `${isFromServer ? 'S' : 'C'} ${packet.state}:${packet.name} ${diff} ${JSON.stringify(data, (key, value) => {
if (typeof value === 'bigint') return value.toString()
return value
})}`
this.logStr(str)
this.lastPacketTime = Date.now()
}
}
export type ParsedReplayPacket = {
name: string
params: any
state: string
diff: number
isFromServer: boolean
}
export function parseReplayContents (contents: string) {
const lines = contents.split('\n')
const packets = [] as ParsedReplayPacket[]
for (let line of lines) {
line = line.trim()
if (!line || line.startsWith('#')) continue
const [side, nameState, diff, ...data] = line.split(' ')
const parsed = JSON.parse(data.join(' '))
const [state, name] = nameState.split(':')
packets.push({
name,
state,
params: parsed,
isFromServer: side.toUpperCase() === 'S',
diff: Number.parseInt(diff.slice(1), 10),
})
}
return packets
}

View file

@ -3,7 +3,7 @@ import { useEffect, useRef, useState } from 'react'
import { activeModalStack, activeModalStacks, hideModal, insertActiveModalStack, miscUiState } from '../globalState'
import { guessProblem } from '../errorLoadingScreenHelpers'
import type { ConnectOptions } from '../connect'
import { downloadPacketsReplay, packetsReplaceSessionState, replayLogger } from '../packetsReplay'
import { downloadPacketsReplay, packetsReplaceSessionState, replayLogger } from '../packetsReplay/packetsReplayLegacy'
import { getProxyDetails } from '../microsoftAuthflow'
import AppStatus from './AppStatus'
import DiveTransition from './DiveTransition'
@ -89,6 +89,7 @@ export default () => {
useEffect(() => {
const controller = new AbortController()
window.addEventListener('keyup', (e) => {
if ('input textarea select'.split(' ').includes((e.target as HTMLElement).tagName?.toLowerCase() ?? '')) return
if (activeModalStack.at(-1)?.reactType !== 'app-status') return
if (e.code !== 'KeyR' || !lastConnectOptions.value) return
reconnect()

View file

@ -2,7 +2,7 @@ import { Meta, Story } from '@storybook/react'
import Book, { BookProps } from './Book'
export default {
title: 'Components/Book',
title: 'Book',
component: Book,
} as Meta

View file

@ -2,10 +2,10 @@ import { useRef, useEffect } from 'react'
import { subscribe, useSnapshot } from 'valtio'
import { useUtilsEffect } from '@zardoy/react-util'
import { options } from '../optionsStorage'
import { activeModalStack, gameAdditionalState, isGameActive, miscUiState } from '../globalState'
import { activeModalStack, isGameActive, miscUiState } from '../globalState'
import worldInteractions from '../worldInteractions'
import { onCameraMove, CameraMoveEvent } from '../cameraRotationControls'
import { pointerLock } from '../utils'
import { pointerLock, isInRealGameSession } from '../utils'
import { handleMovementStickDelta, joystickPointer } from './TouchAreasControls'
/** after what time of holding the finger start breaking the block */
@ -291,7 +291,7 @@ export default function GameInteractionOverlay ({ zIndex }: { zIndex: number })
subscribe(activeModalStack, () => {
if (activeModalStack.length === 0) {
if (isGameActive(false) && !gameAdditionalState.viewerConnection) {
if (isInRealGameSession()) {
void pointerLock.requestPointerLock()
}
} else {

View file

@ -7,8 +7,10 @@ import { activeModalStack, isGameActive, miscUiState } from '../globalState'
import { currentScaling } from '../scaleInterface'
import { watchUnloadForCleanup } from '../gameUnload'
import { getItemNameRaw } from '../mineflayer/items'
import { isInRealGameSession } from '../utils'
import MessageFormattedString from './MessageFormattedString'
import SharedHudVars from './SharedHudVars'
import { packetsReplayState } from './state/packetsReplayState'
const ItemName = ({ itemKey }: { itemKey: string }) => {
@ -147,7 +149,7 @@ const HotbarInner = () => {
bot.on('heldItemChanged' as any, heldItemChanged)
document.addEventListener('wheel', (e) => {
if (!isGameActive(true)) return
if (!isInRealGameSession()) return
e.preventDefault()
const newSlot = ((bot.quickBarSlot + Math.sign(e.deltaY)) % 9 + 9) % 9
setSelectedSlot(newSlot)
@ -157,7 +159,7 @@ const HotbarInner = () => {
})
document.addEventListener('keydown', (e) => {
if (!isGameActive(true)) return
if (!isInRealGameSession()) return
const numPressed = +((/Digit(\d)/.exec(e.code))?.[1] ?? -1)
if (numPressed < 1 || numPressed > 9) return
setSelectedSlot(numPressed - 1)

View file

@ -61,7 +61,7 @@ export default () => {
const scale = useAppScale()
return <div style={{
scale,
transform: `scale(${scale})`,
transformOrigin: 'top right',
}}>
<Notification

View file

@ -0,0 +1,66 @@
import { useSnapshot } from 'valtio'
import { useMemo } from 'react'
import { appQueryParams } from '../appParams'
import { miscUiState } from '../globalState'
import { packetsReplayState } from './state/packetsReplayState'
import ReplayPanel from './ReplayPanel'
function updateQsParam (name: string, value: string | undefined) {
const url = new URL(window.location.href)
if (value) {
url.searchParams.set(name, value)
} else {
url.searchParams.delete(name)
}
window.history.replaceState({}, '', url.toString())
}
export default function PacketsReplayProvider () {
const state = useSnapshot(packetsReplayState)
const { gameLoaded } = useSnapshot(miscUiState)
const autocomplete = useMemo(() => {
if (!loadedData) return
return {
client: Object.keys(loadedData.protocol.play.toClient.types).filter(a => a.startsWith('packet_')).map(a => a.slice('packet_'.length)),
server: Object.keys(loadedData.protocol.play.toServer.types).filter(a => a.startsWith('packet_')).map(a => a.slice('packet_'.length))
}
}, [gameLoaded])
if (!state.isOpen) return null
return (
<div style={{
transform: 'scale(0.5)',
transformOrigin: 'top right'
}}>
<ReplayPanel
replayName={state.replayName}
packets={state.packetsPlayback}
isPlaying={state.isPlaying}
progress={state.progress}
speed={state.speed}
defaultFilter={appQueryParams.replayFilter ?? ''}
clientPacketsAutocomplete={autocomplete?.client ?? []}
serverPacketsAutocomplete={autocomplete?.server ?? []}
customButtons={state.customButtons}
onPlayPause={(isPlaying) => {
packetsReplayState.isPlaying = isPlaying
}}
onRestart={() => {
window.location.reload()
}}
onSpeedChange={(speed) => {
packetsReplayState.speed = speed
updateQsParam('replaySpeed', speed === 1 ? undefined : speed.toString())
}}
onFilterChange={(filter) => {
updateQsParam('replayFilter', filter)
}}
onCustomButtonToggle={(button) => {
packetsReplayState.customButtons[button] = !state.customButtons[button]
}}
/>
</div>
)
}

View file

@ -0,0 +1,106 @@
import type { Meta, StoryObj } from '@storybook/react'
import { proxy, useSnapshot } from 'valtio'
import { useEffect } from 'react'
import ReplayPanel, { PacketData } from './ReplayPanel'
const meta: Meta<typeof ReplayPanel> = {
component: ReplayPanel,
title: 'ReplayPanel'
}
export default meta
type Story = StoryObj<typeof ReplayPanel>
const mockPackets = proxy([
{
name: 'position',
data: { x: 100.123, y: 64.456, z: -200.789 },
isFromClient: true,
isUpcoming: false,
position: 1,
timestamp: 1_234_567_890
},
{
name: 'chat',
data: { message: 'Hello, world!' },
isFromClient: true,
isUpcoming: false,
position: 2,
timestamp: 1_234_567_890
},
{
name: 'block_change',
data: { blockId: 1, position: { x: 100, y: 64, z: -200 } },
isFromClient: false,
isUpcoming: true,
position: 3,
timestamp: 1_234_567_890
},
{
name: 'entity_move',
data: { entityId: 1, x: 100, y: 64, z: -200 },
isFromClient: false,
isUpcoming: false,
actualVersion: { x: 101, y: 64, z: -201 },
position: 4,
timestamp: 1_234_567_890
}
] satisfies PacketData[])
const ReplayPanelWithToggle = (props: Parameters<typeof ReplayPanel>[0]) => {
const packets = useSnapshot(mockPackets)
useEffect(() => {
const interval = setInterval(() => {
for (const [index, packet] of mockPackets.entries()) {
packet.isUpcoming = !packet.isUpcoming
}
}, 3000)
return () => clearInterval(interval)
}, [])
return <ReplayPanel {...props} packets={packets} />
}
export const Primary: Story = {
render: () => (
<ReplayPanelWithToggle
replayName="Test Replay"
clientPacketsAutocomplete={[]}
serverPacketsAutocomplete={[]}
isPlaying={false}
progress={{ current: 0, total: 100 }}
speed={1}
defaultFilter=""
customButtons={{ button1: false, button2: false }}
onPlayPause={() => {}}
onRestart={() => {}}
onSpeedChange={() => {}}
onCustomButtonToggle={() => {}}
onFilterChange={() => {}}
packets={mockPackets}
/>
)
}
export const Playing: Story = {
render: () => (
<ReplayPanelWithToggle
replayName="Test Replay"
clientPacketsAutocomplete={[]}
serverPacketsAutocomplete={[]}
isPlaying={true}
progress={{ current: 50, total: 100 }}
speed={1}
defaultFilter=""
customButtons={{ button1: false, button2: false }}
onPlayPause={() => {}}
onRestart={() => {}}
onSpeedChange={() => {}}
onCustomButtonToggle={() => {}}
onFilterChange={() => {}}
packets={mockPackets}
/>
)
}

172
src/react/ReplayPanel.tsx Normal file
View file

@ -0,0 +1,172 @@
import { useState, useEffect } from 'react'
import { filterPackets } from './packetsFilter'
import { DARK_COLORS } from './components/replay/constants'
import FilterInput from './components/replay/FilterInput'
import PacketList from './components/replay/PacketList'
import ProgressBar from './components/replay/ProgressBar'
interface Props {
replayName: string
packets: readonly PacketData[]
isPlaying: boolean
progress: { current: number; total: number }
speed: number
defaultFilter?: string
customButtons: { button1: boolean; button2: boolean }
onPlayPause?: (isPlaying: boolean) => void
onRestart?: () => void
onSpeedChange?: (speed: number) => void
onFilterChange: (filter: string) => void
onCustomButtonToggle: (button: 'button1' | 'button2') => void
clientPacketsAutocomplete: string[]
serverPacketsAutocomplete: string[]
}
export default function ReplayPanel ({
replayName,
packets,
isPlaying,
progress,
speed,
defaultFilter = '',
customButtons,
onPlayPause,
onRestart,
onSpeedChange,
onFilterChange,
onCustomButtonToggle,
clientPacketsAutocomplete,
serverPacketsAutocomplete
}: Props) {
const [filter, setFilter] = useState(defaultFilter)
const { filtered: filteredPackets, hiddenCount } = filterPackets(packets.slice(-500), filter)
useEffect(() => {
onFilterChange(filter)
}, [filter, onFilterChange])
return (
<div style={{
position: 'fixed',
top: 18,
right: 0,
zIndex: 1000,
background: DARK_COLORS.bg,
padding: '16px',
borderRadius: '0 0 8px 0',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
width: '400px',
maxHeight: '80vh',
display: 'flex',
flexDirection: 'column',
gap: '12px',
color: DARK_COLORS.text
}}>
<div style={{ fontSize: '12px', fontWeight: 'bold' }}>{replayName || 'Unnamed Replay'}</div>
<FilterInput
value={filter}
onChange={setFilter}
hiddenCount={hiddenCount}
shownCount={filteredPackets.length}
onClearFilter={() => setFilter('')}
clientPacketsAutocomplete={clientPacketsAutocomplete}
serverPacketsAutocomplete={serverPacketsAutocomplete}
/>
<PacketList
packets={filteredPackets}
filter={filter}
maxHeight={300}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={() => onPlayPause?.(!isPlaying)}
style={{
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '4px',
color: DARK_COLORS.text
}}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
{isPlaying ? (
<path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z"/>
) : (
<path d="M8 5v14l11-7z"/>
)}
</svg>
</button>
<ProgressBar current={progress.current} total={progress.total} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={onRestart}
style={{
padding: '4px 8px',
borderRadius: '4px',
border: `1px solid ${DARK_COLORS.border}`,
background: DARK_COLORS.input,
color: DARK_COLORS.text,
cursor: 'pointer'
}}
>
Restart
</button>
<input
type="number"
value={speed}
onChange={e => onSpeedChange?.(Number(e.target.value))}
onContextMenu={e => {
e.preventDefault()
onSpeedChange?.(1)
}}
step={0.1}
min={0.1}
style={{
width: '60px',
padding: '4px',
border: `1px solid ${DARK_COLORS.border}`,
borderRadius: '4px',
background: DARK_COLORS.input,
color: DARK_COLORS.text
}}
/>
{[1, 2].map(num => (
<button
key={num}
onClick={() => onCustomButtonToggle(`button${num}` as 'button1' | 'button2')}
style={{
padding: '4px 8px',
borderRadius: '4px',
border: `1px solid ${DARK_COLORS.border}`,
background: customButtons[`button${num}`]
? (num === 1 ? DARK_COLORS.client : DARK_COLORS.server)
: DARK_COLORS.input,
color: DARK_COLORS.text,
cursor: 'pointer'
}}
>
{num}
</button>
))}
</div>
</div>
)
}
export interface PacketData {
name: string
data: any
isFromClient: boolean
isUpcoming: boolean
actualVersion?: any
position: number
timestamp: number
}

View file

@ -0,0 +1,159 @@
import { useEffect, useRef, useState } from 'react'
import { DARK_COLORS } from './constants'
interface Props {
value: string
onChange: (value: string) => void
hiddenCount: number
shownCount: number
onClearFilter: () => void
clientPacketsAutocomplete: string[]
serverPacketsAutocomplete: string[]
}
export default function FilterInput ({
value,
onChange,
hiddenCount,
shownCount,
onClearFilter,
clientPacketsAutocomplete,
serverPacketsAutocomplete
}: Props) {
const inputRef = useRef<HTMLInputElement>(null)
const [showAutocomplete, setShowAutocomplete] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(0)
const allSuggestions = [
...clientPacketsAutocomplete.map(name => ({ name, isClient: true })),
...serverPacketsAutocomplete.map(name => ({ name, isClient: false }))
].sort((a, b) => a.name.localeCompare(b.name))
const currentWord = value.split(/,\s*/).pop() || ''
const filteredSuggestions = allSuggestions.filter(
({ name }) => name.toLowerCase().includes(currentWord.toLowerCase().replace(/^\$/, ''))
)
useEffect(() => {
setSelectedIndex(0)
}, [currentWord])
const acceptSuggestion = (suggestion: string) => {
const parts = value.split(/,\s*/)
parts[parts.length - 1] = suggestion
onChange(parts.join(', '))
setShowAutocomplete(false)
inputRef.current?.focus()
}
return (
<div>
<div style={{ position: 'relative' }}>
<input
ref={inputRef}
type="text"
value={value}
onChange={e => onChange(e.target.value)}
onFocus={() => setShowAutocomplete(true)}
onKeyDown={e => {
if (!showAutocomplete) {
if (e.key === 'Tab') {
e.preventDefault()
setShowAutocomplete(true)
}
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIndex(i => (i + 1) % filteredSuggestions.length)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIndex(i => (i - 1 + filteredSuggestions.length) % filteredSuggestions.length)
} else if (e.key === 'Enter' && filteredSuggestions.length > 0) {
e.preventDefault()
acceptSuggestion(filteredSuggestions[selectedIndex].name)
} else if (e.key === 'Escape') {
e.preventDefault()
setShowAutocomplete(false)
}
}}
placeholder="Filter packets (e.g. entity, $block_display, !position)"
style={{
width: '100%',
padding: '8px',
border: `1px solid ${DARK_COLORS.border}`,
borderRadius: '4px',
background: DARK_COLORS.input,
color: DARK_COLORS.text
}}
/>
{showAutocomplete && filteredSuggestions.length > 0 && (
<div style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
background: DARK_COLORS.bg,
border: `1px solid ${DARK_COLORS.border}`,
borderRadius: '4px',
marginTop: '4px',
maxHeight: '200px',
overflowY: 'auto',
zIndex: 1
}}>
{filteredSuggestions.map(({ name, isClient }, index) => (
<div
key={name}
onClick={() => acceptSuggestion(name)}
style={{
padding: '4px 8px',
cursor: 'pointer',
background: index === selectedIndex ? DARK_COLORS.hover : 'transparent',
color: isClient ? DARK_COLORS.client : DARK_COLORS.server
}}
>
{name}
</div>
))}
</div>
)}
</div>
<div style={{
marginTop: '4px',
fontSize: '12px',
color: DARK_COLORS.textDim,
display: 'flex',
alignItems: 'center',
gap: '4px'
}}>
<span>Showing: {shownCount}</span>
<span></span>
<span>Hidden: {hiddenCount}</span>
<div style={{
opacity: value ? 1 : 0,
gap: '4px',
display: 'flex',
alignItems: 'center'
}}>
<span></span>
<button
onClick={onClearFilter}
style={{
background: 'none',
border: 'none',
padding: 0,
color: DARK_COLORS.text,
cursor: 'pointer',
textDecoration: 'underline',
}}
>
clear all
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,141 @@
import { useRef, useState } from 'react'
import { PacketData } from '../../ReplayPanel'
import { useScrollBehavior } from '../../hooks/useScrollBehavior'
import { DARK_COLORS } from './constants'
const formatters: Record<string, (data: any) => string> = {
position: (data) => `x:${data.x.toFixed(2)} y:${data.y.toFixed(2)} z:${data.z.toFixed(2)}`,
chat: (data) => data.message,
// Add more formatters as needed
}
const getPacketIcon = (name: string): string => {
if (name.includes('position')) return '📍'
if (name.includes('chat')) return '💬'
if (name.includes('block') || name.includes('chunk') || name.includes('light')) return '📦'
if (name.includes('entity') || name.includes('player') || name.includes('passenger')) return '🎯'
return '📄'
}
interface Props {
packets: PacketData[]
filter: string
maxHeight?: number
}
const ROW_HEIGHT = 24
const EXPANDED_HEIGHT = 120
function formatTimeDiff (current: number, prev: number | null): string {
if (prev === null) return ''
const diff = current - prev
return `+${Math.floor(diff / 1000)}`
}
const styles = {
packetRow: {
height: ROW_HEIGHT,
padding: '0 8px',
fontSize: '12px',
display: 'flex',
alignItems: 'center',
gap: '8px',
whiteSpace: 'nowrap',
overflow: 'hidden',
cursor: 'pointer',
transition: 'background-color 0.1s'
} as const,
expandedPacket: {
height: EXPANDED_HEIGHT,
padding: '8px',
background: DARK_COLORS.input,
fontSize: '12px',
overflow: 'auto',
borderBottom: `1px solid ${DARK_COLORS.border}`
} as const
}
export default function PacketList ({ packets, filter, maxHeight = 300 }: Props) {
const listRef = useRef<HTMLDivElement>(null)
const [expandedPacket, setExpandedPacket] = useState<number | null>(null)
const { scrollToBottom } = useScrollBehavior(listRef, { messages: packets, opened: true })
let prevTimestamp: number | null = null
return (
<>
<style>
{`
.packet-row:hover {
background: ${DARK_COLORS.hover} !important;
}
`}
</style>
<div
ref={listRef}
style={{
overflowY: 'auto',
height: maxHeight,
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{ minHeight: '100%' }}>
{packets.map((packet, index) => {
const timeDiff = formatTimeDiff(packet.timestamp, prevTimestamp)
prevTimestamp = packet.timestamp
return (
<div key={`${packet.timestamp}-${packet.position}`}>
<div
className="packet-row"
onClick={() => setExpandedPacket(expandedPacket === packet.position ? null : packet.position)}
style={{
...styles.packetRow,
background: packet.isFromClient ? DARK_COLORS.client : DARK_COLORS.server,
opacity: packet.isUpcoming ? 0.5 : 1
}}
>
<span>{getPacketIcon(packet.name)}</span>
<span style={{ color: DARK_COLORS.textDim }}>
#{packet.position}
{timeDiff && <span style={{ marginLeft: '4px' }}>{timeDiff}</span>}
</span>
{filter && (
<span style={{ color: DARK_COLORS.textDim }}>#{index + 1}</span>
)}
<span style={{
color: packet.actualVersion ? DARK_COLORS.modified : DARK_COLORS.text,
fontWeight: 'bold'
}}>
{packet.name}
</span>
<span style={{ color: DARK_COLORS.textDim, flex: 1, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{formatters[packet.name]?.(packet.data) ?? JSON.stringify(packet.data)}
</span>
</div>
{expandedPacket === packet.position && (
<div style={styles.expandedPacket}>
<div style={{ marginBottom: '8px' }}>
<strong>Data:</strong>
<pre style={{ margin: '4px 0', color: DARK_COLORS.textDim }}>
{JSON.stringify(packet.data, null, 2)}
</pre>
</div>
{packet.actualVersion && (
<div>
<strong>Actual Version:</strong>
<pre style={{ margin: '4px 0', color: DARK_COLORS.textDim }}>
{JSON.stringify(packet.actualVersion, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
)
})}
</div>
</div>
</>
)
}

View file

@ -0,0 +1,36 @@
import { DARK_COLORS } from './constants'
interface Props {
current: number
total: number
}
function padNumber (num: number): string {
return String(num).padStart(3, '0')
}
export default function ProgressBar ({ current, total }: Props) {
return (
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flex: 1 }}>
<div style={{ color: DARK_COLORS.textDim, fontSize: '12px', minWidth: '70px' }}>
{padNumber(current)}/{padNumber(total)}
</div>
<div style={{
flex: 1,
height: '4px',
background: DARK_COLORS.border,
borderRadius: '2px'
}}>
<div
style={{
width: `${(current / total) * 100}%`,
height: '100%',
background: DARK_COLORS.text,
borderRadius: '2px',
transition: 'width 0.2s'
}}
/>
</div>
</div>
)
}

View file

@ -0,0 +1,11 @@
export const DARK_COLORS = {
bg: '#1e1e1e',
input: '#2d2d2d',
text: '#ffffff',
textDim: '#888888',
client: '#144218',
server: '#750200',
modified: '#f57f17',
border: '#333333',
hover: '#404040'
}

View file

@ -0,0 +1,55 @@
import { PacketData } from './ReplayPanel'
function wildcardToRegExp (pattern: string): RegExp {
const escaped = pattern.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')
return new RegExp(`^${escaped.replaceAll('\\*', '.*')}$`)
}
function patternToRegExp (pattern: string): RegExp {
if (pattern.startsWith('$')) {
return new RegExp(`^${pattern.slice(1)}$`)
}
return wildcardToRegExp(`*${pattern}*`)
}
export function parseFilterString (filter: string): { include: RegExp[]; exclude: RegExp[] } {
const parts = filter.split(/,\s*/)
const include: RegExp[] = []
const exclude: RegExp[] = []
for (const part of parts) {
if (!part) continue
if (part.startsWith('!')) {
exclude.push(patternToRegExp(part.slice(1)))
} else {
include.push(patternToRegExp(part))
}
}
return { include, exclude }
}
export function filterPackets (packets: PacketData[], filter: string): { filtered: PacketData[]; hiddenCount: number } {
if (!filter.trim()) {
return { filtered: packets, hiddenCount: 0 }
}
const { include, exclude } = parseFilterString(filter)
const filtered = packets.filter(packet => {
// If packet matches any exclude pattern, filter it out
if (exclude.some(pattern => pattern.test(packet.name))) {
return false
}
// If there are include patterns, packet must match at least one
if (include.length > 0) {
return include.some(pattern => pattern.test(packet.name))
}
// If no include patterns, keep the packet (unless it was excluded)
return true
})
return {
filtered,
hiddenCount: packets.length - filtered.length
}
}

View file

@ -0,0 +1,19 @@
import { proxy } from 'valtio'
import type { PacketData } from '../ReplayPanel'
import { appQueryParams } from '../../appParams'
export const packetsReplayState = proxy({
packetsPlayback: [] as PacketData[],
isOpen: false,
replayName: '',
isPlaying: false,
progress: {
current: 0,
total: 0
},
speed: appQueryParams.replaySpeed ? parseFloat(appQueryParams.replaySpeed) : 1,
customButtons: {
button1: false,
button2: false
}
})

View file

@ -51,6 +51,7 @@ import MineflayerPluginHud from './react/MineflayerPluginHud'
import MineflayerPluginConsole from './react/MineflayerPluginConsole'
import { UIProvider } from './react/UIProvider'
import { useAppScale } from './scaleInterface'
import PacketsReplayProvider from './react/PacketsReplayProvider'
const RobustPortal = ({ children, to }) => {
return createPortal(<PerComponentErrorBoundary>{children}</PerComponentErrorBoundary>, to)
@ -205,6 +206,7 @@ const App = () => {
<TouchAreasControlsProvider />
<SignInMessageProvider />
<NoModalFoundProvider />
<PacketsReplayProvider />
</RobustPortal>
<RobustPortal to={document.body}>
<div className='overlay-top-scaled'>

View file

@ -1,6 +1,7 @@
import { isGameActive, miscUiState } from './globalState'
import { gameAdditionalState, isGameActive, miscUiState } from './globalState'
import { options } from './optionsStorage'
import { notificationProxy, showNotification } from './react/NotificationProvider'
import { packetsReplayState } from './react/state/packetsReplayState'
export const goFullscreen = async (doToggle = false) => {
if (!document.fullscreenElement) {
@ -64,6 +65,10 @@ export const pointerLock = {
}
}
export const isInRealGameSession = () => {
return isGameActive(true) && !packetsReplayState.isOpen && !gameAdditionalState.viewerConnection
}
window.getScreenRefreshRate = getScreenRefreshRate
/**