a huge progress on packet replay component, fix bugs add switches

This commit is contained in:
Vitaly Turovsky 2025-02-23 21:52:12 +03:00
commit bdea1fc50c
14 changed files with 246 additions and 73 deletions

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.1.3",
"mcraft-fun-mineflayer": "^0.1.4",
"peerjs": "^1.5.0",
"pixelarticons": "^1.8.1",
"pretty-bytes": "^6.1.1",

12
pnpm-lock.yaml generated
View file

@ -135,8 +135,8 @@ importers:
specifier: ^4.17.21
version: 4.17.21
mcraft-fun-mineflayer:
specifier: ^0.1.3
version: 0.1.3(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/748163e536abe94f3dc8ada7a542bcd689bbbf49(encoding@0.1.13))
specifier: ^0.1.4
version: 0.1.4(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/748163e536abe94f3dc8ada7a542bcd689bbbf49(encoding@0.1.13))
minecraft-data:
specifier: 3.83.1
version: 3.83.1
@ -6252,9 +6252,9 @@ packages:
resolution: {integrity: sha512-JBi9frIACmzmpKL38YudZJpml+tWP3UuCeb8ko5iJRHpmCmmChE+X3xzVEbEYnYBI2dMiO7915/5eYnKUVys3Q==}
engines: {node: '>=18.0.0'}
mcraft-fun-mineflayer@0.1.3:
resolution: {integrity: sha512-3Xds5XBLwYHFgH09RS9fHNK7NJI2ZStXiYvU/9FVUMd9TOwcMwLorn1kNYOw022/0nGi3hibT3ldj6vLCjASIg==}
version: 0.1.3
mcraft-fun-mineflayer@0.1.4:
resolution: {integrity: sha512-h6Dmnj/1No1aZO9FG14GTCZ80xPHVnkhByUFKIZr0PV2Hto7PoG4hBG8fa1fifwQIgLwBa+bURPyBw3KzzFPhw==}
version: 0.1.4
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
peerDependencies:
'@roamhq/wrtc': '*'
@ -16691,7 +16691,7 @@ snapshots:
apl-image-packer: 1.1.0
zod: 3.24.1
mcraft-fun-mineflayer@0.1.3(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/748163e536abe94f3dc8ada7a542bcd689bbbf49(encoding@0.1.13)):
mcraft-fun-mineflayer@0.1.4(encoding@0.1.13)(mineflayer@https://codeload.github.com/zardoy/mineflayer/tar.gz/748163e536abe94f3dc8ada7a542bcd689bbbf49(encoding@0.1.13)):
dependencies:
'@zardoy/flying-squid': 0.0.49(encoding@0.1.13)
exit-hook: 2.2.1

View file

@ -149,6 +149,14 @@ export abstract class WorldRendererCommon<WorkerSend = any, WorkerReceive = any>
lastChunkDistance = 0
debugStopGeometryUpdate = false
@worldCleanup()
freeFlyMode = false
@worldCleanup()
freeFlyState = {
yaw: 0,
pitch: 0,
position: new Vec3(0, 0, 0)
}
@worldCleanup()
itemsRenderer: ItemsRenderer | undefined

View file

@ -223,8 +223,15 @@ export class WorldRendererThree extends WorldRendererCommon {
}
updateCamera (pos: Vec3 | null, yaw: number, pitch: number): void {
if (this.freeFlyMode) {
pos = this.freeFlyState.position
pitch = this.freeFlyState.pitch
yaw = this.freeFlyState.yaw
}
if (pos) {
new tweenJs.Tween(this.camera.position).to({ x: pos.x, y: pos.y, z: pos.z }, 50).start()
this.freeFlyState.position = pos
}
this.camera.rotation.set(pitch, yaw, this.cameraRoll, 'ZYX')
}
@ -234,7 +241,7 @@ export class WorldRendererThree extends WorldRendererCommon {
// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
const cam = this.camera instanceof THREE.Group ? this.camera.children.find(child => child instanceof THREE.PerspectiveCamera) as THREE.PerspectiveCamera : this.camera
this.renderer.render(this.scene, cam)
if (this.config.showHand) {
if (this.config.showHand && !this.freeFlyMode) {
this.holdingBlock.render(this.camera, this.renderer, viewer.ambientLight, viewer.directionalLight)
this.holdingBlockLeft.render(this.camera, this.renderer, viewer.ambientLight, viewer.directionalLight)
}

View file

@ -45,6 +45,9 @@ export type AppQsParams = {
replaySpeed?: string
replayFileUrl?: string
replayValidateClient?: string
replayStopOnError?: string
replaySkipMissingOnTimeout?: string
replayPacketsSenderDelay?: string
}
export type AppQsParamsArray = {
@ -76,6 +79,16 @@ export const appQueryParamsArray = new Proxy({} as AppQsParamsArrayTransformed,
},
})
export function updateQsParam (name: keyof AppQsParams, 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())
}
// Helper function to check if a specific query parameter exists
export const hasQueryParam = (param: keyof AppQsParams) => qsParams.has(param)

View file

@ -48,6 +48,14 @@ export const moveCameraRawHandler = ({ x, y }: { x: number; y: number }) => {
const minPitch = -0.5 * Math.PI
viewer.world.lastCamUpdate = Date.now()
if (viewer.world.freeFlyMode) {
// Update freeFlyState directly
viewer.world.freeFlyState.yaw = (viewer.world.freeFlyState.yaw - x) % (2 * Math.PI)
viewer.world.freeFlyState.pitch = Math.max(minPitch, Math.min(maxPitch, viewer.world.freeFlyState.pitch - y))
return
}
if (!bot?.entity) return
const pitch = bot.entity.pitch - y
void bot.look(bot.entity.yaw - x, Math.max(minPitch, Math.min(maxPitch, pitch)), true)

View file

@ -2,6 +2,7 @@
import { Vec3 } from 'vec3'
import { proxy, subscribe } from 'valtio'
import * as THREE from 'three'
import { ControMax } from 'contro-max/build/controMax'
import { CommandEventArgument, SchemaCommandInput } from 'contro-max/build/types'
@ -127,6 +128,23 @@ contro.on('movementUpdate', ({ vector, soleVector, gamepadIndex }) => {
}
miscUiState.usingGamepadInput = gamepadIndex !== undefined
if (!bot || !isGameActive(false)) return
if (viewer.world.freeFlyMode) {
// Create movement vector from input
const direction = new THREE.Vector3(0, 0, 0)
if (vector.z !== undefined) direction.z = vector.z
if (vector.x !== undefined) direction.x = vector.x
// Apply camera rotation to movement direction
direction.applyQuaternion(viewer.camera.quaternion)
// Update freeFlyState position with normalized direction
const moveSpeed = 1
direction.multiplyScalar(moveSpeed)
viewer.world.freeFlyState.position.add(new Vec3(direction.x, direction.y, direction.z))
return
}
// gamepadIndex will be used for splitscreen in future
const coordToAction = [
['z', -1, 'forward'],
@ -333,10 +351,20 @@ const onTriggerOrReleased = (command: Command, pressed: boolean) => {
// eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
switch (command) {
case 'general.jump':
bot.setControlState('jump', pressed)
if (viewer.world.freeFlyMode) {
const moveSpeed = 0.5
viewer.world.freeFlyState.position.add(new Vec3(0, pressed ? moveSpeed : 0, 0))
} else {
bot.setControlState('jump', pressed)
}
break
case 'general.sneak':
setSneaking(pressed)
if (viewer.world.freeFlyMode) {
const moveSpeed = 0.5
viewer.world.freeFlyState.position.add(new Vec3(0, pressed ? -moveSpeed : 0, 0))
} else {
setSneaking(pressed)
}
break
case 'general.sprint':
// todo add setting to change behavior

View file

@ -776,7 +776,9 @@ export async function connect (connectOptions: ConnectOptions) {
playerState.onlineMode = !!connectOptions.authenticatedAccount
setLoadingScreenStatus('Placing blocks (starting viewer)')
localStorage.lastConnectOptions = JSON.stringify(connectOptions)
if (connectOptions.worldStateFileContents && connectOptions.worldStateFileContents.length < 3 * 1024 * 1024) {
localStorage.lastConnectOptions = JSON.stringify(connectOptions)
}
connectOptions.onSuccessfulPlay?.()
if (process.env.NODE_ENV === 'development' && !localStorage.lockUrl && !Object.keys(window.debugQueryParams).length) {
lockUrl()

View file

@ -2,6 +2,7 @@
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 MinecraftData from 'minecraft-data'
import { LocalServer } from '../customServer'
import { UserError } from '../mineflayer/userError'
import { packetsReplayState } from '../react/state/packetsReplayState'
@ -24,7 +25,6 @@ interface OpenFileOptions {
export function openFile ({ contents, filename = 'unnamed', filesize }: OpenFileOptions) {
packetsReplayState.replayName = `${filename} (${getFixedFilesize(filesize ?? contents.length)})`
packetsReplayState.isOpen = true
packetsReplayState.isPlaying = false
const connectOptions = {
@ -72,11 +72,12 @@ export const startLocalReplayServer = (contents: string) => {
'online-mode': false
})
server.on('login', async client => {
const data = MinecraftData(def.minecraftVersion)
server.on(data.supportFeature('hasConfigurationState') ? 'playerJoin' : 'login' as any, async client => {
await mainPacketsReplayer(
client,
replayData,
appQueryParams.replayValidateClient === 'true' ? true : undefined
packetsReplayState.customButtons.validateClientPackets.state ? true : undefined
)
})
@ -122,6 +123,8 @@ const IGNORE_SERVER_PACKETS = new Set([
'kick_disconnect',
])
const ADDITIONAL_DELAY = 500
const mainPacketsReplayer = async (client: ServerClient, replayData: ReturnType<typeof parseReplayContents>, ignoreClientPacketsWait: string[] | true = []) => {
const writePacket = (name: string, data: any) => {
data = restoreData(data)
@ -139,49 +142,107 @@ const mainPacketsReplayer = async (client: ServerClient, replayData: ReturnType<
expectedPacketReceived (name, params) {
console.log('expectedPacketReceived', name, params)
addPacketToReplayer(name, params, true, true)
},
unexpectedPacketsLimit: 15,
onUnexpectedPacketsLimitReached () {
addPacketToReplayer('...', {}, 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,
})
}
// Patch console.error to detect errors
const originalConsoleError = console.error
let lastSentPacket: { name: string, params: any } | null = null
console.error = (...args) => {
if (lastSentPacket) {
console.log('Got error after packet', lastSentPacket.name, lastSentPacket.params)
}
originalConsoleError.apply(console, args)
if (packetsReplayState.customButtons.stopOnError.state) {
packetsReplayState.isPlaying = false
throw new Error('Replay stopped due to error: ' + args.join(' '))
}
}
await clientsPacketsWaiter.waitForPackets(clientPackets.map(p => p.name))
clientPackets = []
const playServerPacket = (name: string, params: any) => {
try {
writePacket(name, params)
addPacketToReplayer(name, params, false)
lastSentPacket = { name, params }
} catch (err) {
console.error('Error processing packet:', err)
if (packetsReplayState.customButtons.stopOnError.state) {
packetsReplayState.isPlaying = false
}
}
}
try {
bot.on('error', (err) => {
console.error('Mineflayer error:', err)
})
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 (!packetsReplayState.isPlaying) {
await new Promise<void>(resolve => {
const interval = setInterval(() => {
if (packetsReplayState.isPlaying) {
clearInterval(interval)
resolve()
}
}, 100)
})
}
if (packet.isFromServer) {
playServerPacket(packet.name, packet.params)
await new Promise(resolve => {
setTimeout(resolve, packet.diff * packetsReplayState.speed + ADDITIONAL_DELAY * (packetsReplayState.customButtons.packetsSenderDelay.state ? 1 : 0))
})
} 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 Promise.race([
clientsPacketsWaiter.waitForPackets(clientPackets.map(p => p.name)),
...(packetsReplayState.customButtons.skipMissingOnTimeout.state ? [new Promise(resolve => {
setTimeout(resolve, 1000)
})] : [])
])
clientPackets = []
}
}
}
} finally {
// Restore original console.error
console.error = originalConsoleError
}
}
interface PacketsWaiterOptions {
unexpectedPacketReceived?: (name: string, params: any) => void
expectedPacketReceived?: (name: string, params: any) => void
onUnexpectedPacketsLimitReached?: () => void
unexpectedPacketsLimit?: number
}
interface PacketsWaiter {
@ -193,13 +254,19 @@ const createPacketsWaiter = (options: PacketsWaiterOptions = {}): PacketsWaiter
let packetHandler: ((data: any, name: string) => void) | null = null
const queuedPackets: Array<{ name: string, params: any }> = []
let isWaiting = false
let unexpectedPacketsCount = 0
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 (options.unexpectedPacketsLimit && unexpectedPacketsCount < options.unexpectedPacketsLimit) {
options.unexpectedPacketReceived?.(name, data)
}
if (options.onUnexpectedPacketsLimitReached && unexpectedPacketsCount === options.unexpectedPacketsLimit) {
options.onUnexpectedPacketsLimitReached?.()
}
unexpectedPacketsCount++
}
if (waitingPackets.length === 0) {
@ -220,6 +287,7 @@ const createPacketsWaiter = (options: PacketsWaiterOptions = {}): PacketsWaiter
if (isWaiting) {
throw new Error('Already waiting for packets')
}
unexpectedPacketsCount = 0
isWaiting = true
try {
@ -253,6 +321,7 @@ const isArrayEqual = (a: any[], b: any[]) => {
}
const restoreData = (json: any) => {
if (!json) return json
const keys = Object.keys(json)
if (isArrayEqual(keys.sort(), ['data', 'type'].sort())) {

View file

@ -27,7 +27,8 @@ export const Primary: Story = {
_title: { text: 'Boss', translate: 'entity.minecraft.ender_dragon' },
_color: 'red',
_dividers: 2,
_health: 100
_health: 100,
lastUpdated: 0
}
}
}

View file

@ -1,20 +1,10 @@
import { useSnapshot } from 'valtio'
import { useMemo } from 'react'
import { appQueryParams } from '../appParams'
import { appQueryParams, updateQsParam } from '../appParams'
import { miscUiState } from '../globalState'
import { packetsReplayState } from './state/packetsReplayState'
import { onChangeButtonState, 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)
@ -58,7 +48,7 @@ export default function PacketsReplayProvider () {
updateQsParam('replayFilter', filter)
}}
onCustomButtonToggle={(button) => {
packetsReplayState.customButtons[button] = !state.customButtons[button]
onChangeButtonState(button as keyof typeof packetsReplayState.customButtons, !state.customButtons[button].state)
}}
/>
</div>

View file

@ -12,12 +12,12 @@ interface Props {
progress: { current: number; total: number }
speed: number
defaultFilter?: string
customButtons: { button1: boolean; button2: boolean }
customButtons: Readonly<Record<string, { state: boolean; label: string; tooltip?: string }>>
onPlayPause?: (isPlaying: boolean) => void
onRestart?: () => void
onSpeedChange?: (speed: number) => void
onFilterChange: (filter: string) => void
onCustomButtonToggle: (button: 'button1' | 'button2') => void
onCustomButtonToggle: (buttonId: string) => void
clientPacketsAutocomplete: string[]
serverPacketsAutocomplete: string[]
}
@ -139,22 +139,24 @@ export default function ReplayPanel ({
}}
/>
{[1, 2].map(num => (
{Object.entries(customButtons).map(([buttonId, { state, label, tooltip }]) => (
<button
key={num}
onClick={() => onCustomButtonToggle(`button${num}` as 'button1' | 'button2')}
key={buttonId}
onClick={() => onCustomButtonToggle(buttonId)}
title={tooltip}
style={{
padding: '4px 8px',
borderRadius: '4px',
border: `1px solid ${DARK_COLORS.border}`,
background: customButtons[`button${num}`]
? (num === 1 ? DARK_COLORS.client : DARK_COLORS.server)
background: state
? (buttonId.startsWith('client') ? DARK_COLORS.client : DARK_COLORS.server)
: DARK_COLORS.input,
color: DARK_COLORS.text,
cursor: 'pointer'
cursor: 'pointer',
minWidth: '32px'
}}
>
{num}
{label}
</button>
))}
</div>

View file

@ -1,12 +1,17 @@
import { useRef, useState } from 'react'
import { PacketData } from '../../ReplayPanel'
import { useScrollBehavior } from '../../hooks/useScrollBehavior'
import { ClientOnMap } from '../../../generatedServerPackets'
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
// chat: (data) => data,
map_chunk (data: ClientOnMap['map_chunk'] | any) {
const sizeOfChunk = data.chunkData?.length
const blockEntitiesCount = data.blockEntities?.length
return `x:${data.x} z:${data.z} C:${sizeOfChunk} E:${blockEntitiesCount}`
},
}
const getPacketIcon = (name: string): string => {

View file

@ -1,6 +1,6 @@
import { proxy } from 'valtio'
import type { PacketData } from '../ReplayPanel'
import { appQueryParams } from '../../appParams'
import { appQueryParams, updateQsParam } from '../../appParams'
export const packetsReplayState = proxy({
packetsPlayback: [] as PacketData[],
@ -13,7 +13,47 @@ export const packetsReplayState = proxy({
},
speed: appQueryParams.replaySpeed ? parseFloat(appQueryParams.replaySpeed) : 1,
customButtons: {
button1: false,
button2: false
validateClientPackets: {
state: appQueryParams.replayValidateClient === 'true',
label: 'C',
tooltip: 'Validate client packets'
},
stopOnError: {
state: appQueryParams.replayStopOnError === 'true',
label: 'E',
tooltip: 'Stop the replay when an error occurs'
},
skipMissingOnTimeout: {
state: appQueryParams.replaySkipMissingOnTimeout === 'true',
label: 'M',
tooltip: 'Skip missing packets on timeout'
},
packetsSenderDelay: {
state: appQueryParams.replayPacketsSenderDelay === 'true',
label: 'D',
tooltip: 'Send packets with an additional delay'
}
}
})
export const onChangeButtonState = (button: keyof typeof packetsReplayState.customButtons, state: boolean) => {
packetsReplayState.customButtons[button].state = state
switch (button) {
case 'validateClientPackets': {
updateQsParam('replayValidateClient', String(state))
break
}
case 'stopOnError': {
updateQsParam('replayStopOnError', String(state))
break
}
case 'skipMissingOnTimeout': {
updateQsParam('replaySkipMissingOnTimeout', String(state))
break
}
case 'packetsSenderDelay': {
updateQsParam('replayPacketsSenderDelay', String(state))
break
}
}
}