feat(netproto): encode 1.13c C2S 0x5F UpdatePlayerPos

5-byte position resync request (u8 0x5F, u16 x, u16 y) built by D2Client
0x6FAC49E0 when its position gate finds the local player out of
tolerance; D2Game 0x6FC88530 answers with 0x15 when it disagrees.
This commit is contained in:
troytt 2026-10-02 05:13:40 +00:00
parent 531273c989
commit ea252b0414
4 changed files with 60 additions and 0 deletions

View File

@ -0,0 +1,29 @@
/**
* D2GS C->S Player Position Resync Packet Builder (0x5F, 1.13c).
*/
import { ByteWriter } from '../../core/byte-writer.ts'
import { validateC2sPacket } from '../framing-c2s.ts'
function assertU16(value: number, what: string): void {
if (!Number.isInteger(value) || value < 0 || value > 0xffff) {
throw new RangeError(`0x5F UpdatePlayerPos ${what} must be a u16, got ${String(value)}`)
}
}
/**
* 0x5F UpdatePlayerPos (5B): `u8 0x5F | u16 x | u16 y`, the client's own sub-tile.
*
* D2Client 0x6FAC49E0 builds it when the position gate 0x6FADA1B0 finds the local player out
* of tolerance; D2Game handles it at 0x6FC88530 (D2MOO `PlrMsg.cpp`
* `D2GAME_PACKETCALLBACK_Rcv0x5F_UpdatePlayerPos`), answering with `0x15` when it disagrees.
*/
export function encodeC2sUpdatePlayerPos(x: number, y: number): Uint8Array {
assertU16(x, 'x')
assertU16(y, 'y')
const w = new ByteWriter(5)
w.u8(0x5f)
w.u16LE(x)
w.u16LE(y)
return validateC2sPacket(w.toUint8Array())
}

View File

@ -61,6 +61,7 @@ import {
encodeC2sSkillOnUnit,
} from './c2s/skills.ts'
import { encodeC2sTrade } from './c2s/trade.ts'
import { encodeC2sUpdatePlayerPos } from './c2s/position.ts'
import { D2GS_S2C_PACKET_SIZES, resolveS2cPacketSize } from './framing-s2c.ts'
import { decode0x26ChatMessage, decode0x5AEventMessage } from './s2c/chat.ts'
import {
@ -436,6 +437,8 @@ export function encodeD2gsClientCommand(
return [encodeC2sMoveToUnit(false, { unitType: cmd.unitKind, id: cmd.unitId })]
case 'RunToTarget':
return [encodeC2sMoveToUnit(true, { unitType: cmd.unitKind, id: cmd.unitId })]
case 'UpdatePlayerPos':
return [encodeC2sUpdatePlayerPos(cmd.x, cmd.y)]
case 'SkillAt':
return [encodeC2sSkillAt(cmd.hand, cmd.at, cmd.shift)]
case 'CastLeftSkillOnLocation':

View File

@ -11,6 +11,7 @@ export type ClientCommand =
| { readonly type: 'MoveToUnit'; readonly run: boolean; readonly target: UnitRef }
| { readonly type: 'WalkToTarget'; readonly unitKind: UnitType; readonly unitId: number }
| { readonly type: 'RunToTarget'; readonly unitKind: UnitType; readonly unitId: number }
| { readonly type: 'UpdatePlayerPos'; readonly x: number; readonly y: number }
| { readonly type: 'SkillAt'; readonly hand: Hand; readonly at: Pt; readonly shift?: boolean | undefined }
| { readonly type: 'CastLeftSkillOnLocation'; readonly x: number; readonly y: number }
| { readonly type: 'CastRightSkillOnLocation'; readonly x: number; readonly y: number }

View File

@ -0,0 +1,27 @@
/**
* C2S 0x5F UpdatePlayerPos (D2 1.13c): `u8 0x5F | u16 x | u16 y`, sent by D2Client 0x6FAC49E0 when
* the position gate 0x6FADA1B0 finds the local player out of tolerance.
*/
import { describe, expect, it } from 'vitest'
import { encodeC2sUpdatePlayerPos } from '../../src/netproto/d2gs/c2s/position.ts'
import { encodeD2gsClientCommand } from '../../src/netproto/d2gs/registry.ts'
describe('C2S 0x5F UpdatePlayerPos', () => {
it('encodes the sub-tile as two little-endian u16 values', () => {
expect([...encodeC2sUpdatePlayerPos(0x1234, 0xbeef)]).toEqual([0x5f, 0x34, 0x12, 0xef, 0xbe])
expect([...encodeC2sUpdatePlayerPos(0, 0xffff)]).toEqual([0x5f, 0x00, 0x00, 0xff, 0xff])
})
it('is what the UpdatePlayerPos client command sends', () => {
const packets = encodeD2gsClientCommand({ type: 'UpdatePlayerPos', x: 5000, y: 4999 })
expect(packets.map(p => [...p])).toEqual([[0x5f, 0x88, 0x13, 0x87, 0x13]])
})
it('rejects coordinates that are not u16', () => {
for (const bad of [-1, 0x10000, 1.5, Number.NaN]) {
expect(() => encodeC2sUpdatePlayerPos(bad, 100)).toThrow(RangeError)
expect(() => encodeC2sUpdatePlayerPos(100, bad)).toThrow(RangeError)
}
})
})