From 15673a68944e0b0aca6394f3b646ee6f7aeaff7f Mon Sep 17 00:00:00 2001 From: Hani Barkallah Date: Sat, 10 Oct 2026 10:57:02 +0100 Subject: [PATCH 1/4] fix(game): read Account's newer tail and a server's faction, decode Join Account's tail changed: six zeros before the repeated Server and a bare 03 after it, where it used to be four zeros and 03 09 03. Both forms are read now. The byte before the tail, previously skipped, is read as Faction (1 on a Light server, 2 on a Dark one in every sample seen). Join (39 0D), the server the client picked in the lobby, is read: 00 00 00 then the server ID. Fixtures and tests cover both Account tails, Join, and a Servers list whose last group of four holds only two entries. --- game/lobby.go | 9 +++- game/opcode.go | 6 +-- game/parse.go | 24 +++++++++-- game/parse_test.go | 62 ++++++++++++++++++++++++++++ game/testdata/account-v2.bin | Bin 0 -> 102 bytes game/testdata/account-v2.expect.txt | 1 + game/testdata/account.expect.txt | 2 +- game/testdata/join.bin | Bin 0 -> 8 bytes game/testdata/join.expect.txt | 1 + 9 files changed, 96 insertions(+), 9 deletions(-) create mode 100644 game/testdata/account-v2.bin create mode 100644 game/testdata/account-v2.expect.txt create mode 100644 game/testdata/join.bin create mode 100644 game/testdata/join.expect.txt diff --git a/game/lobby.go b/game/lobby.go index 6ba7f93..7fc5243 100644 --- a/game/lobby.go +++ b/game/lobby.go @@ -76,10 +76,16 @@ func (d Redirect) Addr() (netip.AddrPort, bool) { return netip.AddrPortFrom(ip, d.Port), err == nil } +// Join is the server the client picked in the lobby, sent before the Redirect to it. +type Join struct { + Server uint16 // the ID in Servers, the same as the Redirect's +} + // Account is who logged in to the lobby. type Account struct { ID string // the account's number Server uint16 // likely the one joined last, the ID in Servers + Faction byte // the side it plays, 1 for Light (Elyos) and 2 for Dark (Asmodians), as in Server.Faction Session [2]string // two GUIDs, new at every login (unsure which one is which, or what they are used for) } @@ -98,7 +104,7 @@ type Character struct { Level uint32 // the character's level Exp uint32 // @TODO: not sure what this is, but it's sent for every character Code uint32 // 5 to 44 so far, not read - Flags byte // 1, and 2 on one placeholder, not read + Flags byte // likely its side, 1 for Light (Elyos) and 2 for Dark (Asmodians) as in Server.Faction, not read Mask byte // its two bits of the byte after every fourth character, not read Time time.Time // likely when it was last played } @@ -109,5 +115,6 @@ func (c Character) String() string { return fmt.Sprintf("%d:%s/%d", c.Server, c. func (Servers) event() {} func (LobbyPing) event() {} func (Redirect) event() {} +func (Join) event() {} func (Account) event() {} func (Characters) event() {} diff --git a/game/opcode.go b/game/opcode.go index 6dda168..7abcdd7 100644 --- a/game/opcode.go +++ b/game/opcode.go @@ -15,7 +15,7 @@ var opcodes = map[wire.Opcode]op{ // 36: the login, the world, and its clocks 0x3600: {"Tick", tick}, 0x3603: {"Ping", ping}, - 0x3611: {"Handshake", nil}, // a login's first frame + 0x3611: {"Handshake", nil}, // a login's first frame: 00 00, a u32 60000, a varint 256 and that many random bytes, then 12 more (8 zeros, f9 ff ff ff) 0x3615: {"Login", nil}, // the login time, that time plus 8 h, and the account 0x361A: {"NameCheck", nameCheck}, 0x3623: {"Zone", zone}, @@ -25,12 +25,12 @@ var opcodes = map[wire.Opcode]op{ 0x3645: {"Player", player}, // 39: the lobby, which lists the servers and hands the client to the one picked - 0x3901: {"LobbyHandshake", nil}, // the lobby's first frame + 0x3901: {"LobbyHandshake", nil}, // the lobby's first frame: 00 00, a u16 60, a varint 256 and that many random bytes, then 10 zeros 0x3903: {"LobbyPing", lobbyPing}, 0x3906: {"Account", account}, 0x3909: {"Servers", servers}, 0x390B: {"Characters", characters}, - 0x390D: {"Join", nil}, // the server picked + 0x390D: {"Join", join}, // the server picked 0x390F: {"Redirect", redirect}, // 37: movement diff --git a/game/parse.go b/game/parse.go index 2bc27cb..1b1aa93 100644 --- a/game/parse.go +++ b/game/parse.go @@ -2,6 +2,7 @@ package game import ( "bytes" + "encoding/binary" "fmt" "strings" "time" @@ -250,6 +251,19 @@ func redirect(r *reader) Event { return d } +// join parses the server the client picked in the lobby from a frame +func join(r *reader) Event { + if r.u16() != 0 || r.u8() != 0 { + r.bad = true // 00 00 00 so far + } + j := Join{Server: r.u16()} + if j.Server == 0 { + r.bad = true + } + r.end() + return j +} + // account parses who logged in to the lobby from a frame func account(r *reader) Event { if r.u16() != 0 || r.u8() != 0 { @@ -267,12 +281,14 @@ func account(r *reader) Event { } a.ID, a.Session[1] = id, session a.Server = r.u16() - r.skip(5) // 01, then a u32 0 so far - if r.u16() != a.Server { + a.Faction = r.u8() // 01 on a Light server, 02 on a Dark one so far + + // What follows is nine bytes that hold the Server again. In older captures four zeros come before it, and 03 09 03 after, + // in the one from 10 October 2026 six zeros come before it, and a bare 03 after. + t := r.take(9) + if t != nil && binary.LittleEndian.Uint16(t[4:]) != a.Server && binary.LittleEndian.Uint16(t[6:]) != a.Server { r.bad = true } - - r.skip(3) // 03 09 03 so far r.end() return a diff --git a/game/parse_test.go b/game/parse_test.go index 95edc6b..5b981e1 100644 --- a/game/parse_test.go +++ b/game/parse_test.go @@ -225,6 +225,61 @@ func TestParseCharactersShapes(t *testing.T) { } } +// Both tails of an account hold its server twice: the first form until some patch, the second since it, as in the capture of 10 October 2026. +func TestParseAccountTails(t *testing.T) { + head := "00 00 00 01 41 01 00 00 03 31 3a 42 " + for _, tt := range []struct { + name, tail string + server uint16 + faction byte + }{ + {"four zeros then 03 09 03", "17 05 01 00 00 00 00 17 05 03 09 03", 1303, 1}, + {"six zeros then 03", "06 09 02 00 00 00 00 00 00 06 09 03", 2310, 2}, + {"the server 0903, which the first form holds as its constant", "03 09 02 00 00 00 00 00 00 03 09 03", 2307, 2}, + } { + e, err := game.Parse(wire.Frame{Opcode: 0x3906, Payload: unhex(t, head+tt.tail)}) + want := game.Account{ID: "1", Server: tt.server, Faction: tt.faction, Session: [2]string{"A", "B"}} + if err != nil || !reflect.DeepEqual(e, want) { + t.Errorf("%s: got %+v, %v; want %+v", tt.name, e, err, want) + } + } +} + +// The last group of four holds fewer, and its mask byte carries bits for those alone. +func TestParseServersGroups(t *testing.T) { + payload := unhex(t, "00 00 06"+ + " 0e 09 02 0e 09 00 00 03 44 5f 31 55 05 13 50 01 00"+ + " 0f 09 02 0f 09 00 00 03 44 5f 32 01 10 50 01 00"+ + " 10 09 02 10 09 00 00 03 44 5f 33 01 0e 50 01 00"+ + " 11 09 02 11 09 00 00 03 44 5f 34 01 0d 50 01 00"+ + " 12 09 02 12 09 00 00 03 44 5f 35 05 01 0f 50 01 00"+ + " 13 09 02 13 09 00 00 03 44 5f 36 01 0e 50 01 00"+ + " 01 01") + e, err := game.Parse(wire.Frame{Opcode: 0x3909, Payload: payload}) + if err != nil { + t.Fatal(err) + } + got := e.(game.Servers).List + if len(got) != 6 { + t.Fatalf("got %d servers", len(got)) + } + for i, s := range got { + if s.Mask != 1 || s.ID != uint16(2318+i) || s.Faction != 2 { + t.Errorf("server %d: %+v", i, s) + } + } + if s := fmt.Sprint(got); s != "[2318:D_1 19% new restricted 2319:D_2 16% new 2320:D_3 14% new 2321:D_4 13% new 2322:D_5 15% new 2323:D_6 14% new]" { + t.Errorf("printed %s", s) + } +} + +func TestParseJoin(t *testing.T) { + e, err := game.Parse(wire.Frame{Opcode: 0x390D, Payload: unhex(t, "00 00 00 06 09")}) + if want := (game.Join{Server: 2310}); err != nil || e != want { + t.Errorf("got %+v, %v; want %+v", e, err, want) + } +} + func TestRedirectAddr(t *testing.T) { for _, tt := range []struct { payload string @@ -286,6 +341,13 @@ func TestParseRejects(t *testing.T) { {"characters cut short in a character", 0x390B, "00 00 01 15 05 01 41 03 00 00 00", game.ErrLayout}, {"characters without its trailer", 0x390B, "00 00 01 15 05 01 41 03 00 00 00 00 00 00 00 00 00 00 00 06 00 00 00 01 81 e0 d7 f8 a0 01 00 00 00 00 00 00 00 00 00 00 00", game.ErrLayout}, {"account whose Server is not repeated", 0x3906, "00 00 00 01 41 01 00 00 03 31 3a 42 17 05 01 00 00 00 00 18 05 03 09 03", game.ErrLayout}, + {"account of the newer form whose Server is not repeated", 0x3906, "00 00 00 01 41 01 00 00 03 31 3a 42 06 09 02 00 00 00 00 00 00 07 09 03", game.ErrLayout}, + {"account of the newer form cut before its 03", 0x3906, "00 00 00 01 41 01 00 00 03 31 3a 42 06 09 02 00 00 00 00 00 00 06 09", game.ErrLayout}, + {"account with a byte too many", 0x3906, "00 00 00 01 41 01 00 00 03 31 3a 42 06 09 02 00 00 00 00 00 00 06 09 03 00", game.ErrLayout}, + {"join cut short", 0x390D, "00 00 00 06", game.ErrLayout}, + {"join whose first bytes are not 0", 0x390D, "00 00 01 06 09", game.ErrLayout}, + {"join of server 0", 0x390D, "00 00 00 00 00", game.ErrLayout}, + {"join with a byte too many", 0x390D, "00 00 00 06 09 00", game.ErrLayout}, {"account whose ID has no colon", 0x3906, "00 00 00 01 41 01 00 00 01 42 17 05 01 00 00 00 00 17 05 03 09 03", game.ErrLayout}, {"characters whose trailer is not 01 01", 0x390B, "00 00 00 01 02", game.ErrLayout}, {"characters on server 0", 0x390B, "00 00 01 00 00 01 41 03 00 00 00 00 00 00 00 00 00 00 00 06 00 00 00 01 81 e0 d7 f8 a0 01 00 00 00 00 00 00 00 00 00 00 00 01 01", game.ErrLayout}, diff --git a/game/testdata/account-v2.bin b/game/testdata/account-v2.bin new file mode 100644 index 0000000000000000000000000000000000000000..d32c82cfc75735e890037e23d0bdf9687303c956 GIT binary patch literal 102 zcmc~~vt(dkP%$(zHZe6bx6rk8baK{padk7$1qtbb#6UtI5k>|EZ9_1yB1SnIClk Date: Sat, 10 Oct 2026 14:46:25 +0100 Subject: [PATCH 2/4] feat(game): decode Turn (39 1D), a lighter movement update with no position The flag byte's 0x08 bit was skipped before; it marks whether the message carries one extra byte. After that, a 16-bit value is always sent twice, then a trailing 01. Verified against a real capture: the value matches across repeats for the same entity and differs between entities, but no sample has shown it change for one entity, so what it represents (heading, most likely) isn't confirmed -- only the byte layout is. 0x371D moves from the wire decoder's family-match fallback to its known list, alongside its 371A-371C siblings; `go generate ./wire` found no fixture drift from the change. --- game/opcode.go | 2 +- game/parse.go | 19 +++++++++++++++++++ game/parse_test.go | 20 ++++++++++++++++++++ game/testdata/turn.bin | 1 + game/testdata/turn.expect.txt | 1 + game/world.go | 10 ++++++++++ wire/opcode.go | 2 +- 7 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 game/testdata/turn.bin create mode 100644 game/testdata/turn.expect.txt diff --git a/game/opcode.go b/game/opcode.go index 7abcdd7..1906e1a 100644 --- a/game/opcode.go +++ b/game/opcode.go @@ -37,7 +37,7 @@ var opcodes = map[wire.Opcode]op{ 0x371A: {"Move", moveA}, 0x371B: {"Move", moveB}, 0x371C: {"Move", moveB}, - 0x371D: {"Move", nil}, // 9–12 bytes, a sibling + 0x371D: {"Turn", turn}, // a lighter update than Move, with no position // 38: skills 0x3802: {"Cast", cast}, diff --git a/game/parse.go b/game/parse.go index 1b1aa93..2262de9 100644 --- a/game/parse.go +++ b/game/parse.go @@ -178,6 +178,25 @@ func ping(r *reader) Event { return p } +// turn parses a lighter movement update, with no position, from a frame +func turn(r *reader) Event { + t := Turn{Entity: r.entity()} + flag := r.u8() + r.skip(2) // changes with the entity's movement, not understood + if flag&0x08 != 0 { + r.skip(1) // one more byte, seen only when this bit is set + } + t.Heading = r.u16() + if r.u16() != t.Heading { + r.bad = true // sent twice, always the same so far + } + if r.u8() != 1 { + r.bad = true // 01 so far + } + r.end() + return t +} + // zone parses a zone event from a frame func zone(r *reader) Event { if r.varint() != 0 { diff --git a/game/parse_test.go b/game/parse_test.go index 5b981e1..d1af30e 100644 --- a/game/parse_test.go +++ b/game/parse_test.go @@ -273,6 +273,22 @@ func TestParseServersGroups(t *testing.T) { } } +// Both forms of the lead the flag's 0x08 bit chooses, with the one extra byte when it's set. +func TestParseTurn(t *testing.T) { + for _, tt := range []struct { + name, payload string + want game.Turn + }{ + {"short lead", "64 02 11 22 34 12 34 12 01", game.Turn{Entity: 100, Heading: 0x1234}}, + {"long lead", "ac 02 08 33 44 55 cd ab cd ab 01", game.Turn{Entity: 300, Heading: 0xABCD}}, + } { + e, err := game.Parse(wire.Frame{Opcode: 0x371D, Payload: unhex(t, tt.payload)}) + if err != nil || e != tt.want { + t.Errorf("%s: got %+v, %v; want %+v", tt.name, e, err, tt.want) + } + } +} + func TestParseJoin(t *testing.T) { e, err := game.Parse(wire.Frame{Opcode: 0x390D, Payload: unhex(t, "00 00 00 06 09")}) if want := (game.Join{Server: 2310}); err != nil || e != want { @@ -317,6 +333,10 @@ func TestParseRejects(t *testing.T) { {"hit with switch nibble 6 cut in its three bytes", 0x3804, "c7 7c 06 00 f5 a3 02 e0 26 a8 00 00 02 00 00", game.ErrLayout}, {"hit with switch nibble 5", 0x3804, "c7 7c 05 00 f5 a3 02 e0 26 a8 00 00 02 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 90 4e 20 01 00", game.ErrUnread}, {"hit whose type does not fit a byte", 0x3804, "c7 7c 04 00 f5 a3 02 e0 26 a8 00 00 ac 02 4b ed 9f 41 01 00 00 00 90 4e 24 01 00", game.ErrLayout}, + {"turn cut short", 0x371D, "64 02 11 22 34 12 34 12", game.ErrLayout}, + {"turn whose heading is not repeated", 0x371D, "64 02 11 22 34 12 00 00 01", game.ErrLayout}, + {"turn whose trailer is not 01", 0x371D, "64 02 11 22 34 12 34 12 02", game.ErrLayout}, + {"turn with a byte too many", 0x371D, "64 02 11 22 34 12 34 12 01 00", game.ErrLayout}, {"hit whose trailer is not its sequence number", 0x3804, "c7 7c 04 00 f5 a3 02 e0 26 a8 00 00 02 4b ed 9f 41 01 00 00 00 90 4e 24 02 00", game.ErrLayout}, {"hit with bytes after the trailer", 0x3804, "c7 7c 04 00 f5 a3 02 e0 26 a8 00 00 02 4b ed 9f 41 01 00 00 00 90 4e 24 01 00 ff", game.ErrLayout}, {"hit claiming more extra hits than bytes", 0x3804, "c7 7c 24 00 f5 a3 02 e0 26 a8 00 00 02 4b ed 9f 41 01 00 00 00 90 4e 24 7f 01 00", game.ErrLayout}, diff --git a/game/testdata/turn.bin b/game/testdata/turn.bin new file mode 100644 index 0000000..62ff2b4 --- /dev/null +++ b/game/testdata/turn.bin @@ -0,0 +1 @@ +7d"44 \ No newline at end of file diff --git a/game/testdata/turn.expect.txt b/game/testdata/turn.expect.txt new file mode 100644 index 0000000..e0e624f --- /dev/null +++ b/game/testdata/turn.expect.txt @@ -0,0 +1 @@ +1D 37 game.Turn {Entity:100 Heading:4660} diff --git a/game/world.go b/game/world.go index 921e4f4..02278c1 100644 --- a/game/world.go +++ b/game/world.go @@ -23,6 +23,15 @@ type Move struct { Pos } +// Turn is a lighter update than Move, with no position. +// +// Every sample seen so far sends Heading twice, always the same value, so what a change would +// look like, or whether this is a heading at all, is not confirmed. +type Turn struct { + Entity Entity + Heading uint16 +} + // Zone places the client's character in the world after a zone change type Zone struct{ Pos } @@ -36,5 +45,6 @@ type NameCheck struct { func (Spawn) event() {} func (Player) event() {} func (Move) event() {} +func (Turn) event() {} func (Zone) event() {} func (NameCheck) event() {} diff --git a/wire/opcode.go b/wire/opcode.go index 675fa96..b7c6649 100644 --- a/wire/opcode.go +++ b/wire/opcode.go @@ -50,7 +50,7 @@ var known = [...]Opcode{ 0x3804, 0x3805, 0x3802, 0x3806, 0x3822, 0x382A, 0x382B, 0x382C, 0x3835, 0x3847, 0x3603, 0x361A, 0x3623, 0x3633, 0x3640, 0x3641, 0x3642, 0x3645, 0x3646, 0x3647, 0x3649, 0x364A, 0x3600, // Server tick - 0x371A, 0x371B, 0x371C, + 0x371A, 0x371B, 0x371C, 0x371D, 0x8D00, 0x8D04, 0x8D21, 0x921B, 0x9200, 0x920D, 0x3901, 0x3903, 0x3906, 0x3909, 0x390B, 0x390D, 0x390F, // Lobby opcodes From 74a97d46abb79e87f53156cb6b02dce558d74a53 Mon Sep 17 00:00:00 2001 From: Hani Barkallah Date: Sat, 10 Oct 2026 16:28:06 +0100 Subject: [PATCH 3/4] feat(game): decode Heading (28/29/2A 37) -- direction, speed, and sometimes position 28 37 and 29 37 carry an entity, a flag byte (04 or 02, likely redundant with the opcode), a position, then a shared 15-byte tail: two headings, a speed, a changing u16, and a trailing 01. 2A 37 ends with the same 15-byte tail but has no position -- what comes before it instead varies in length (1 to 3 bytes so far) and isn't understood. Rather than guess the rule behind that variable lead, headingOnly anchors the tail from the end of the frame, using the length the reader already knows. Verified against a real capture: 2A 37's Heading1/Heading2/Speed match the position-carrying 28/29 37 exactly for the same entity at the same moment. Which of Heading1/Heading2 is current vs. a target, if either, and what the two opcodes' difference and the skipped u16 mean, are not confirmed -- only the byte layout is. wire/opcode.go gains 3728/3729/372A in its known list; `go generate ./wire` found no fixture drift. --- game/opcode.go | 5 +++- game/parse.go | 33 +++++++++++++++++++++++++++ game/parse_test.go | 31 +++++++++++++++++++++++++ game/testdata/heading-pos.bin | Bin 0 -> 32 bytes game/testdata/heading-pos.expect.txt | 1 + game/testdata/heading.bin | Bin 0 -> 22 bytes game/testdata/heading.expect.txt | 1 + game/world.go | 13 +++++++++++ wire/opcode.go | 2 +- 9 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 game/testdata/heading-pos.bin create mode 100644 game/testdata/heading-pos.expect.txt create mode 100644 game/testdata/heading.bin create mode 100644 game/testdata/heading.expect.txt diff --git a/game/opcode.go b/game/opcode.go index 1906e1a..b98033b 100644 --- a/game/opcode.go +++ b/game/opcode.go @@ -37,7 +37,10 @@ var opcodes = map[wire.Opcode]op{ 0x371A: {"Move", moveA}, 0x371B: {"Move", moveB}, 0x371C: {"Move", moveB}, - 0x371D: {"Turn", turn}, // a lighter update than Move, with no position + 0x371D: {"Turn", turn}, // a lighter update than Move, with no position + 0x3728: {"Heading", headingWithPos}, // flag 04, with a position + 0x3729: {"Heading", headingWithPos}, // flag 02, with a position + 0x372A: {"Heading", headingOnly}, // no position // 38: skills 0x3802: {"Cast", cast}, diff --git a/game/parse.go b/game/parse.go index 2262de9..6b82bcb 100644 --- a/game/parse.go +++ b/game/parse.go @@ -197,6 +197,39 @@ func turn(r *reader) Event { return t } +// headingWithPos parses 28 37 and 29 37's entity, position and heading from a frame +func headingWithPos(r *reader) Event { + h := Heading{Entity: r.entity()} + r.skip(1) // 04 on 28 37, 02 on 29 37, likely redundant with the opcode + h.Pos = r.pos() + return heading(r, h) +} + +// headingOnly parses 2A 37's entity and heading from a frame, with no position +func headingOnly(r *reader) Event { + h := Heading{Entity: r.entity()} + if n := r.left() - 15; n >= 0 { + r.skip(n) // not understood; its length varies + } else { + r.bad = true + } + return heading(r, h) +} + +// heading reads the 15 bytes 28 37, 29 37 and 2A 37 end with: two headings, a speed, a changing +// u16 not understood, and a trailing 01. +func heading(r *reader, h Heading) Event { + h.Heading1 = r.f32() + h.Heading2 = r.f32() + h.Speed = r.f32() + r.skip(2) // changes often, not understood + if r.u8() != 1 { + r.bad = true // 01 so far + } + r.end() + return h +} + // zone parses a zone event from a frame func zone(r *reader) Event { if r.varint() != 0 { diff --git a/game/parse_test.go b/game/parse_test.go index d1af30e..54c4d5f 100644 --- a/game/parse_test.go +++ b/game/parse_test.go @@ -289,6 +289,34 @@ func TestParseTurn(t *testing.T) { } } +// 28/29 37 carry a position before the shared heading tail. +func TestParseHeadingWithPos(t *testing.T) { + e, err := game.Parse(wire.Frame{Opcode: 0x3728, Payload: unhex(t, + "64 04 00 00 80 42 00 00 a0 42 00 00 c0 42 00 00 90 42 00 00 a8 42 00 00 70 41 11 22 01")}) + want := game.Heading{Entity: 100, Pos: game.Pos{X: 64, Y: 80, Z: 96}, + Heading1: 72, Heading2: 84, Speed: 15} + if err != nil || e != want { + t.Errorf("got %+v, %v; want %+v", e, err, want) + } +} + +// 2A 37 has no position, and its lead before the shared tail varies in length. +func TestParseHeadingOnly(t *testing.T) { + for _, tt := range []struct { + name, lead string + }{ + {"short lead", "12"}, + {"long lead", "16 0d 05"}, + } { + payload := unhex(t, tt.lead+" 00 00 80 42 00 00 a0 42 00 00 c0 42 11 22 01") + e, err := game.Parse(wire.Frame{Opcode: 0x372A, Payload: append(unhex(t, "64"), payload...)}) + want := game.Heading{Entity: 100, Heading1: 64, Heading2: 80, Speed: 96} + if err != nil || e != want { + t.Errorf("%s: got %+v, %v; want %+v", tt.name, e, err, want) + } + } +} + func TestParseJoin(t *testing.T) { e, err := game.Parse(wire.Frame{Opcode: 0x390D, Payload: unhex(t, "00 00 00 06 09")}) if want := (game.Join{Server: 2310}); err != nil || e != want { @@ -337,6 +365,9 @@ func TestParseRejects(t *testing.T) { {"turn whose heading is not repeated", 0x371D, "64 02 11 22 34 12 00 00 01", game.ErrLayout}, {"turn whose trailer is not 01", 0x371D, "64 02 11 22 34 12 34 12 02", game.ErrLayout}, {"turn with a byte too many", 0x371D, "64 02 11 22 34 12 34 12 01 00", game.ErrLayout}, + {"heading too short for its tail", 0x372A, "64 12 00 00 80 42 00 00 a0 42 00 00 c0 42 11", game.ErrLayout}, + {"heading whose trailer is not 01", 0x3728, "64 04 00 00 80 42 00 00 a0 42 00 00 c0 42 00 00 90 42 00 00 a8 42 00 00 70 41 11 22 02", game.ErrLayout}, + {"heading with a byte too many", 0x372A, "64 12 00 00 80 42 00 00 a0 42 00 00 c0 42 11 22 01 00", game.ErrLayout}, {"hit whose trailer is not its sequence number", 0x3804, "c7 7c 04 00 f5 a3 02 e0 26 a8 00 00 02 4b ed 9f 41 01 00 00 00 90 4e 24 02 00", game.ErrLayout}, {"hit with bytes after the trailer", 0x3804, "c7 7c 04 00 f5 a3 02 e0 26 a8 00 00 02 4b ed 9f 41 01 00 00 00 90 4e 24 01 00 ff", game.ErrLayout}, {"hit claiming more extra hits than bytes", 0x3804, "c7 7c 24 00 f5 a3 02 e0 26 a8 00 00 02 4b ed 9f 41 01 00 00 00 90 4e 24 7f 01 00", game.ErrLayout}, diff --git a/game/testdata/heading-pos.bin b/game/testdata/heading-pos.bin new file mode 100644 index 0000000000000000000000000000000000000000..97c94f76730a01e664114d3234ac7e2142947d3e GIT binary patch literal 32 hcmY$^Fi&A&U}$h+U|0ae2Y`425U&8@0!Kk5MgV7W1~LEu literal 0 HcmV?d00001 diff --git a/game/testdata/heading-pos.expect.txt b/game/testdata/heading-pos.expect.txt new file mode 100644 index 0000000..7fe5513 --- /dev/null +++ b/game/testdata/heading-pos.expect.txt @@ -0,0 +1 @@ +28 37 game.Heading {Entity:100 Pos:{X:64 Y:80 Z:96} Heading1:72 Heading2:84 Speed:15} diff --git a/game/testdata/heading.bin b/game/testdata/heading.bin new file mode 100644 index 0000000000000000000000000000000000000000..02b0ba5e5337c89153f6199e83ecab2896c6bade GIT binary patch literal 22 bcmb2tGEWiXWo2M!aAIIs0K^BJ1eF*8DyIYC literal 0 HcmV?d00001 diff --git a/game/testdata/heading.expect.txt b/game/testdata/heading.expect.txt new file mode 100644 index 0000000..4d9a1cd --- /dev/null +++ b/game/testdata/heading.expect.txt @@ -0,0 +1 @@ +2A 37 game.Heading {Entity:100 Pos:{X:0 Y:0 Z:0} Heading1:64 Heading2:80 Speed:96} diff --git a/game/world.go b/game/world.go index 02278c1..8de57ab 100644 --- a/game/world.go +++ b/game/world.go @@ -32,6 +32,18 @@ type Turn struct { Heading uint16 } +// Heading is an entity's current and turning direction, with its speed. +// +// 28 37 and 29 37 also carry its position; 2A 37 doesn't, and Pos is the zero value then. What +// tells 28 37 apart from 29 37, what precedes 2A 37's fields when there's no position, and which +// of Heading1 and Heading2 is the current direction and which, if either, is a target, are not +// understood. Speed is zero at rest in every sample seen so far, which fits that name. +type Heading struct { + Entity Entity + Pos // the zero value when this update carries none + Heading1, Heading2, Speed float32 +} + // Zone places the client's character in the world after a zone change type Zone struct{ Pos } @@ -46,5 +58,6 @@ func (Spawn) event() {} func (Player) event() {} func (Move) event() {} func (Turn) event() {} +func (Heading) event() {} func (Zone) event() {} func (NameCheck) event() {} diff --git a/wire/opcode.go b/wire/opcode.go index b7c6649..2d724e1 100644 --- a/wire/opcode.go +++ b/wire/opcode.go @@ -50,7 +50,7 @@ var known = [...]Opcode{ 0x3804, 0x3805, 0x3802, 0x3806, 0x3822, 0x382A, 0x382B, 0x382C, 0x3835, 0x3847, 0x3603, 0x361A, 0x3623, 0x3633, 0x3640, 0x3641, 0x3642, 0x3645, 0x3646, 0x3647, 0x3649, 0x364A, 0x3600, // Server tick - 0x371A, 0x371B, 0x371C, 0x371D, + 0x371A, 0x371B, 0x371C, 0x371D, 0x3728, 0x3729, 0x372A, 0x8D00, 0x8D04, 0x8D21, 0x921B, 0x9200, 0x920D, 0x3901, 0x3903, 0x3906, 0x3909, 0x390B, 0x390D, 0x390F, // Lobby opcodes From dd0a3dcbd85f600e2f6aa6b791dabd1c0deeacfc Mon Sep 17 00:00:00 2001 From: Hani Barkallah Date: Sat, 10 Oct 2026 17:40:43 +0100 Subject: [PATCH 4/4] feat(game): decode the October 2026 patch's layouts, and name the rest of a session's opcodes Read from a 500 s recording (16111 server messages): decoded 11353 -> 15613, failed 945 -> 0. Fixed, each checked against every sample in the recording: - Turn (1D 37): the flag's low four bits each add one byte before the two headings, and the trailing 01 is sent only with flag bit 0 clear. The old layout failed 945 of 980. New Heading2 field: the two differ on a few. - Spawn (41 36): the mask is two bytes since the patch, four before it; both are read. Positions match the entity's later Move. - Hit (04 38): switch nibble 0 sends no damage varint. - Cast (02 38): the short form is read, and Angle and Duration added. Decoded: - Stats (00 8D): an entity's attributes, a mask then u32 and u64 groups. - Spawn (34 36): entities that never moved, Move (2F 37). Guessed, named and read in game/guess.go, each doc comment saying what the guess rests on: Target, Combo, SkillUse, Windup, Engage, Threat, Attributes, Height, Effect, Area, Cutscene, Script, and the generic Ref, Tagged and Signal. The rest of the types in the recording are named from their family and not read. wire keeps the guessed opcodes in a list of their own: KnownOnly keeps them, and the lock does not count them, as a longer known list made noise look like the game's frames twice as often (internal/score). Fixtures are frames of the recording; the synthetic Turn and Cast ones, whose layouts the recording contradicts, are replaced. --- cmd/a2k/print.go | 2 + cmd/a2k/stats.go | 4 +- game/combat.go | 11 +- game/guess.go | 155 +++++++++++++++ game/opcode.go | 188 ++++++++++++++++++ game/opcode_test.go | 10 +- game/parse.go | 336 +++++++++++++++++++++++++++++++-- game/parse_test.go | 179 +++++++++++++++--- game/testdata/cast.bin | Bin 187 -> 232 bytes game/testdata/cast.expect.txt | 12 +- game/testdata/hit.bin | Bin 291 -> 375 bytes game/testdata/hit.expect.txt | 3 + game/testdata/move.bin | Bin 185 -> 256 bytes game/testdata/move.expect.txt | 2 + game/testdata/spawn.bin | Bin 464 -> 905 bytes game/testdata/spawn.expect.txt | 5 + game/testdata/stats.bin | Bin 0 -> 108 bytes game/testdata/stats.expect.txt | 5 + game/testdata/turn.bin | 2 +- game/testdata/turn.expect.txt | 11 +- game/world.go | 30 ++- trace_test.go | 18 +- wire/decoder.go | 2 +- wire/framer.go | 2 +- wire/opcode.go | 28 ++- 25 files changed, 932 insertions(+), 73 deletions(-) create mode 100644 game/guess.go create mode 100644 game/testdata/stats.bin create mode 100644 game/testdata/stats.expect.txt diff --git a/cmd/a2k/print.go b/cmd/a2k/print.go index 844bbfe..6faf103 100644 --- a/cmd/a2k/print.go +++ b/cmd/a2k/print.go @@ -141,6 +141,8 @@ func fields(e game.Event) string { fmt.Fprintf(&b, "%.1f", x) case time.Time: b.WriteString(x.Local().Format("15:04:05.000")) + case []byte: + fmt.Fprintf(&b, "%x", x) case string: if strings.ContainsAny(x, " \"=") { x = fmt.Sprintf("%q", x) diff --git a/cmd/a2k/stats.go b/cmd/a2k/stats.go index 0b53335..2dc026b 100644 --- a/cmd/a2k/stats.go +++ b/cmd/a2k/stats.go @@ -177,11 +177,13 @@ func writeTable(w io.Writer, a *a2kit.Analysis, marks []time.Time, window time.D } // note says what is known of a type. -// The lock counts it, and it comes whether the player acts or not. +// The lock counts it, and it comes whether the player acts or not. A type that is guessed is read, but the lock does not count it. func note(op wire.Opcode, background bool) string { var notes []string if op.Known() { notes = append(notes, "known") + } else if op.Guessed() { + notes = append(notes, "guessed") } if background { notes = append(notes, "background") diff --git a/game/combat.go b/game/combat.go index 2306aba..9322901 100644 --- a/game/combat.go +++ b/game/combat.go @@ -1,6 +1,9 @@ package game // Hit is the damage one skill deals one target, from 04 38. +// +// Damage is 0 on a hit that deals none, which is a form of its own and never otherwise 0. Skills +// 4001 and 4002 of the player send it with the player for the target as well, and others do. type Hit struct { Actor, Target Entity Skill Skill @@ -11,11 +14,17 @@ type Hit struct { Mods, Direction byte // with some hits, else 0; their bits are not read } -// Cast opens a skill use, from 02 38. Its position is the target's. +// Cast opens a skill use, from 02 38. Its position is the target's, and the zero value on the short +// form, which also comes with another entity as the target. +// +// For skills 4001 and 4002 Pos is not a position: its X and Y are a vector of length 100 and Z is 0. +// Angle is a direction in degrees, as far as its range can tell. type Cast struct { Actor, Target Entity Skill Skill + Angle float32 Pos + Duration uint32 // a guess: milliseconds, 500 to 10000, and 0 on the short form } // CastEnd closes the skill use a Cast opened, from 06 38. diff --git a/game/guess.go b/game/guess.go new file mode 100644 index 0000000..3dba737 --- /dev/null +++ b/game/guess.go @@ -0,0 +1,155 @@ +package game + +import "time" + +// What is in this file is read from one recording and named by a guess. A name, a field or a +// layout here may change when another recording says otherwise. Each doc comment says what the +// guess rests on. + +// Ref names an entity and nothing more. +type Ref struct{ Entity Entity } + +// Tagged is a message that starts with an entity, followed by the bytes a2k does not read yet. +type Tagged struct { + Entity Entity + Data []byte +} + +// Signal is a message of one to four bytes and no entity, which holds a small number, 0 so far. +type Signal struct{ Code uint32 } + +// Target is the entity a mob has picked to attack, from 35 38, with Target 0 when it lets go. +// +// Guess: it follows a mob's first Cast on the player by a few tenths of a second, and ends with +// a 0 when the fight is over. +type Target struct{ Entity, Target Entity } + +// Combo is a chain of the player's skills, from 18 38: the skill that was used, and the one that +// can follow it. From is 0 when the message, 19 38, names only the next skill. +// +// Guess: the three skills 9865 to 9867 and the next three 9868 to 9870 follow each other in the +// order a basic attack chain would, one of these each time the player casts one. +type Combo struct{ From, To Skill } + +// SkillUse is the player's own Cast, again, from 01 38. +// +// Guess: it comes with each of the player's own Cast, with its skill. Info is 0 on most, and +// 0x1813 on a few that came late. +type SkillUse struct { + Skill Skill + Info uint16 +} + +// Windup announces a Cast before it comes, from 09 38, as a mob's skill is about to land. +// +// Guess: it comes 500 to 600 ms before the Cast of the same skill from the same mob, and carries +// a number of the same size in Duration, in milliseconds. +type Windup struct { + Actor, Target Entity + Skill Skill + Angle float32 + Pos + Duration uint32 +} + +// Engage names the mob that is about to pick the player as its Target, from 34 38. The entity is 0 on some. +// +// Guess: it comes right before Target, with the same mob in it. +type Engage struct{ Entity Entity } + +// Threat is the other end of a fight with a mob, from 31 38: the mob, and a number that varies. +// +// Guess: it comes with the mob's Target letting go, and its number, a u16, is not understood. +type Threat struct { + Entity Entity + Value uint16 +} + +// Attributes is an entity's whole table of attributes, from 49 36 for the player's own, which +// has no entity, and 4A 36 for another's. +// +// Guess: the ids are the game's attribute ids and a value goes with each. Ids 0x5d and 0x59 +// hold 1500 and 2500 on a new character, and 0x60 and 0x61 hold 100000, as Stats' 1 and 3 do. +type Attributes struct { + Entity Entity + Values []Attribute +} + +// Attribute is one of Attributes' ids and its value. +type Attribute struct { + ID uint16 + Value int32 +} + +// Height is an entity's height, from 46 37. +// +// Guess: the value is the Z of the entity's position, to the unit, in every sample. +type Height struct { + Entity Entity + Z float32 +} + +// Effect is something put on an entity, from 2A 38, and the change of one from 2B 38, a buff or +// a debuff. +// +// Guess: Instance counts up with each one, an effect's ID repeats, and what Value holds changes +// with it, 5000 or 300 or 200, which looks like its strength or its length. Time is when, and +// Source is who. The kinds seen are 01, 11 and 13: 02 adds a Skill and 10 adds a position. +type Effect struct { + Entity Entity + Kind byte + Instance uint32 + ID uint32 + Value uint32 // 0xFFFFFFFF with the permanent ones + Time time.Time + Source Entity + Skill Skill + Refresh bool // 2B 38 + Pos +} + +// Area is a skill that lands somewhere, from 03 38, and from 0E 38 with a second position. +// +// Guess: a mob's skill, with the player for its target, comes right after the Cast of a mob. +// The layout of what follows the position is not understood, and is not read. +type Area struct { + Actor, Target Entity + Skill Skill + Instance uint32 + Pos + Pos2 Pos // 0E 38 only +} + +// Cutscene starts a scripted scene for the player, from 21 36, at a position, and with the scene's name. +// +// Guess: its names are Cutscene_L_A_QM_1001 and Cutscene_L_A_Seq_1278, and 15 8D names the same one a moment later. +type Cutscene struct { + Kind uint32 // 1 to 5 + ID uint32 // 100001 mostly + Name string // a name comes with some of them + Pos +} + +// Script is a named trigger of the game's scripts, from 15 8D: a cutscene, a talk, or a step of the tutorial. +// +// Guess: its names are Cutscene_L_A_Seq_1273, Talk_L_Starter_InstanceLayer_1_08 and Tutorial_Basic_05. +type Script struct { + Flag, Kind byte // Kind is 09 for a cutscene, 13 for a talk, 2E for a tutorial + Name string +} + +func (Ref) event() {} +func (Tagged) event() {} +func (Signal) event() {} +func (Target) event() {} +func (Combo) event() {} +func (SkillUse) event() {} +func (Windup) event() {} +func (Engage) event() {} +func (Threat) event() {} +func (Attributes) event() {} +func (Height) event() {} +func (Effect) event() {} +func (Area) event() {} +func (Cutscene) event() {} +func (Script) event() {} diff --git a/game/opcode.go b/game/opcode.go index b98033b..f73a8ea 100644 --- a/game/opcode.go +++ b/game/opcode.go @@ -23,6 +23,7 @@ var opcodes = map[wire.Opcode]op{ 0x3641: {"Spawn", spawn}, 0x3642: {"Death", death}, 0x3645: {"Player", player}, + 0x3634: {"Spawn", spawnStatic}, // 39: the lobby, which lists the servers and hands the client to the one picked 0x3901: {"LobbyHandshake", nil}, // the lobby's first frame: 00 00, a u16 60, a varint 256 and that many random bytes, then 10 zeros @@ -41,6 +42,7 @@ var opcodes = map[wire.Opcode]op{ 0x3728: {"Heading", headingWithPos}, // flag 04, with a position 0x3729: {"Heading", headingWithPos}, // flag 02, with a position 0x372A: {"Heading", headingOnly}, // no position + 0x372F: {"Move", moveB}, // with more fields after the position, not understood // 38: skills 0x3802: {"Cast", cast}, @@ -48,12 +50,198 @@ var opcodes = map[wire.Opcode]op{ 0x3805: {"DoT", nil}, 0x3806: {"CastEnd", castEnd}, + 0x8D00: {"Stats", stats}, 0x8D04: {"Owner", owner}, 0x8D2F: {"Notice", nil}, // text to the players, the same in each of its slots 0x921B: {"HP", nil}, 0x9702: {"Party", nil}, } +// guesses are opcodes named, and read, on the strength of one recording. Each is a guess until +// another confirms it, see guess.go, and each comment says what the guess is. +var guesses = map[wire.Opcode]op{ + // 36: the world + 0x3618: {"Signal", signal}, + 0x3621: {"Cutscene", cutscene}, + 0x3631: {"Signal", signal}, + 0x3635: {"ObjectState", tagged}, // of the objects that never move: two small numbers, and 00 + 0x3637: {"InteractAck", leadEntity}, // the last of five messages that name one object after the player touches it + 0x3638: {"Interact", tagged}, // an object, the player, and 01 + 0x363A: {"InteractEnd", tagged}, // an object, the player, and eight zeros + 0x363B: {"InteractDone", ref}, // an object + 0x3646: {"Ready", tagged}, // the player's entity and 00, as a zone is entered + 0x3647: {"State", tagged}, // an entity and two bytes that change + 0x3649: {"Attributes", attributes}, + 0x364A: {"Attributes", attributesOf}, + 0x364B: {"Status", tagged}, // a mob, 01, an id, and a value that is 3000 or -2500 + 0x364E: {"Waypoint", tagged}, // an entity, two floats under 500, and 00 or 01 + + // 37: movement + 0x371E: {"Move", moveFlagged}, + 0x3721: {"Land", ref}, // players only + 0x3722: {"Stop", ref}, // the player and others + 0x3723: {"Move", moveFlagged}, + 0x3724: {"Move", moveFlagged}, + 0x372B: {"Move", moveFlagged}, + 0x372E: {"Move", moveLead}, + 0x373C: {"Move", moveFlagged}, + 0x3741: {"Rest", tagged}, // the player's entity and a u16 + 0x3742: {"Jump", ref}, // the player only + 0x3746: {"Height", height}, + + // 38: skills + 0x3801: {"SkillUse", skillUse}, + 0x3803: {"Area", area}, + 0x3809: {"Windup", windup}, + 0x380C: {"Casting", tagged}, // a mob, 00 00, a skill and 00 00 + 0x380E: {"Area", area}, + 0x3818: {"Combo", combo}, + 0x3819: {"Combo", comboNext}, + 0x382A: {"Effect", effectAdded}, + 0x382B: {"Effect", effectChanged}, + 0x382C: {"Charge", tagged}, // an entity, 01 00, and two bytes that count up with the player's casts + 0x3831: {"Threat", threat}, + 0x3834: {"Engage", engage}, + 0x3835: {"Target", target}, + 0x383B: {"ActionBegin", signal}, // 0.1 s before 3D 38, when the player starts a skill of a chain + 0x383D: {"ActionEnd", signal}, + + // 8D + 0x8D03: {"Notify", tagged}, // an entity and four zeros + 0x8D15: {"Script", script}, + 0x8D21: {"Notify", tagged}, // an entity, 00, 01 + 0x8D41: {"Signal", signal}, + 0x8D43: {"Signal", signal}, + 0x8D52: {"Notify", ref}, // a mob + 0x8D6D: {"InteractOpen", ref}, + 0x8D91: {"Notify", tagged}, // a player, 02 00 00 00 + 0x8D93: {"Notify", tagged}, // an object, 01 00 00 00 + + // seen but not read: the names are guesses from what is in them + 0x8D53: {"Season", nil}, // names like Season_Main_Live + 0xE349: {"Mail", nil}, // a gift mail's title and text + 0x5600: {"Skills", nil}, // the player's skill ids, each twice, and a flag + 0x5684: {"Item", nil}, // one item: an id, a count, and the time it came + 0x5683: {"Items", nil}, // 39691 bytes at login, a table that holds Item's ids + 0x5611: {"Bag", nil}, // 819 bytes at login + 0x922E: {"MapEvent", nil}, // an id, and a name like World_L_Starter_Layer_1_1_MapEvent + 0x922D: {"MapEventState", nil}, // the same id, and two bytes that change + 0x9231: {"MapEventState", nil}, // another event's id, and a count + 0xFFA5: {"Time", nil}, // a zero u64, and the time as the other messages carry it + 0xFFAC: {"Time", nil}, + 0xFFA9: {"Time", nil}, + 0x9508: {"MobState", nil}, // a mob, 20001 or 20003, and 02 + + // seen once or twice, mostly as a zone loads: named from the family and what is in them + 0xE324: {"Quest", nil}, // 24 E3 + 0xE335: {"QuestScene", nil}, // 35 E3 + 0xE343: {"QuestList", nil}, // 43 E3 + 0xE32A: {"QuestList", nil}, // 2A E3 + 0xE33A: {"QuestMarker", nil}, // 3A E3 + 0xE347: {"QuestState", nil}, // 47 E3 + 0xE002: {"QuestState", nil}, // 02 E0 + 0xE004: {"QuestState", nil}, // 04 E0 + 0xE200: {"Settings", nil}, // 00 E2 + 0xE215: {"Settings", nil}, // 15 E2 + 0xE223: {"Settings", nil}, // 23 E2 + 0xE224: {"Settings", nil}, // 24 E2 + 0xE22E: {"Settings", nil}, // 2E E2 + 0xE241: {"Settings", nil}, // 41 E2 + 0xE243: {"Settings", nil}, // 43 E2 + 0xE248: {"Settings", nil}, // 48 E2 + 0xE250: {"Settings", nil}, // 50 E2 + 0xE251: {"Settings", nil}, // 51 E2 + 0xE256: {"Settings", nil}, // 56 E2 + 0xE258: {"Settings", nil}, // 58 E2 + 0xE25A: {"Settings", nil}, // 5A E2 + 0xE25C: {"Settings", nil}, // 5C E2 + 0xE262: {"Settings", nil}, // 62 E2 + 0xE263: {"Settings", nil}, // 63 E2 + 0xE274: {"Settings", nil}, // 74 E2 + 0xE28C: {"Settings", nil}, // 8C E2 + 0xE28D: {"Settings", nil}, // 8D E2 + 0xE213: {"Settings", nil}, // 13 E2 + 0x4001: {"SceneState", nil}, // 01 40 + 0x4002: {"SceneState", nil}, // 02 40 + 0x400A: {"SceneState", nil}, // 0A 40 + 0x6000: {"SceneState", nil}, // 00 60 + 0x6100: {"SceneData", nil}, // 00 61 + 0x6101: {"SceneData", nil}, // 01 61 + 0x610B: {"SceneData", nil}, // 0B 61 + 0x4301: {"Achievement", nil}, // 01 43 + 0x5100: {"Currency", nil}, // 00 51 + 0x3656: {"Currency", nil}, // 56 36 + 0x3657: {"Currency", nil}, // 57 36 + 0x561B: {"ItemUpdate", nil}, // 1B 56 + 0x561C: {"ItemUpdate", nil}, // 1C 56 + 0x5648: {"ItemUpdate", nil}, // 48 56 + 0x5613: {"ItemUpdate", nil}, // 13 56 + 0x561D: {"ItemCount", nil}, // 1D 56 + 0x8A1D: {"Chat", nil}, // 1D 8A + 0x9000: {"Social", nil}, // 00 90 + 0x9330: {"Social", nil}, // 30 93 + 0x9331: {"Social", nil}, // 31 93 + 0x361C: {"Social", nil}, // 1C 36 + 0x3620: {"Social", nil}, // 20 36 + 0x3626: {"Social", nil}, // 26 36 + 0x9501: {"Emotes", nil}, // 01 95 + 0x8D10: {"Instance", nil}, // 10 8D + 0x8D2C: {"Instance", nil}, // 2C 8D + 0x8D11: {"Instance", nil}, // 11 8D + 0x8D09: {"Instance", nil}, // 09 8D + 0x8D7E: {"Catalog", nil}, // 7E 8D + 0x8D8C: {"Catalog", nil}, // 8C 8D + 0x922F: {"MapEventState", nil}, // 2F 92 + 0x9232: {"MapEventList", nil}, // 32 92 + 0x372D: {"Move", nil}, // 2D 37 + 0x3725: {"Move", nil}, // 25 37 + 0x3732: {"Move", nil}, // 32 37 + 0x3720: {"Move", nil}, // 20 37 + 0x3730: {"Move", nil}, // 30 37 + 0x3734: {"Move", nil}, // 34 37 + 0x3900: {"SkillSlots", nil}, // 00 39 + 0xE33B: {"Signal", nil}, // 3B E3 + 0xE33D: {"Signal", nil}, // 3D E3 + 0xE33E: {"Signal", nil}, // 3E E3 + 0xE342: {"Signal", nil}, // 42 E3 + 0xE226: {"Signal", nil}, // 26 E2 + 0xE237: {"Signal", nil}, // 37 E2 + 0xE257: {"Signal", nil}, // 57 E2 + 0x8D58: {"Signal", nil}, // 58 8D + 0x8D37: {"Signal", nil}, // 37 8D + 0x8AAF: {"Signal", nil}, // AF 8A + 0x5633: {"Signal", nil}, // 33 56 + 0x564B: {"Signal", nil}, // 4B 56 + 0x5672: {"Signal", nil}, // 72 56 + 0x56B6: {"Signal", nil}, // B6 56 + 0x56AD: {"Signal", nil}, // AD 56 + 0x5679: {"Signal", nil}, // 79 56 + 0x5682: {"Signal", nil}, // 82 56 + 0x56A8: {"Signal", nil}, // A8 56 + 0x570F: {"Signal", nil}, // 0F 57 + 0x572D: {"Signal", nil}, // 2D 57 + 0x6103: {"Signal", nil}, // 03 61 + 0x6108: {"Signal", nil}, // 08 61 + 0x6109: {"Signal", nil}, // 09 61 + 0x363E: {"Signal", nil}, // 3E 36 + 0x8D44: {"Notify", nil}, // 44 8D + 0x8D4B: {"Notify", nil}, // 4B 8D + 0x8D57: {"Notify", nil}, // 57 8D + 0x8D5B: {"Notify", nil}, // 5B 8D + 0x8D4E: {"Notify", nil}, // 4E 8D + 0x8D01: {"Notify", nil}, // 01 8D + 0x8D0F: {"Notify", nil}, // 0F 8D +} + +func init() { + for o, g := range guesses { + if _, ok := opcodes[o]; ok { + panic("game: " + o.String() + " is both known and guessed") + } + opcodes[o] = g + } +} + // Name is what the table calls o, or "" for an opcode it has not met. func Name(o wire.Opcode) string { return opcodes[o].name } diff --git a/game/opcode_test.go b/game/opcode_test.go index 5f56dab..aac2be8 100644 --- a/game/opcode_test.go +++ b/game/opcode_test.go @@ -2,11 +2,17 @@ package game import "testing" -// KnownOnly keeps only known opcodes, so one that game reads and wire does not know never reaches Parse. +// KnownOnly keeps only known and guessed opcodes, so one that game reads and wire does not list never reaches Parse. func TestReadsKnown(t *testing.T) { for op, o := range opcodes { - if o.read != nil && !op.Known() { + _, guess := guesses[op] + switch { + case o.read != nil && !op.Known() && !op.Guessed(): t.Errorf("%v %s is read, but wire does not know it", op, o.name) + case guess && op.Known() && op.Guessed(): + t.Errorf("%v %s is both known and guessed in wire", op, o.name) + case !guess && op.Guessed(): + t.Errorf("%v %s is guessed in wire, and not in game", op, o.name) } } } diff --git a/game/parse.go b/game/parse.go index 6b82bcb..b1efedb 100644 --- a/game/parse.go +++ b/game/parse.go @@ -4,6 +4,7 @@ import ( "bytes" "encoding/binary" "fmt" + "math/bits" "strings" "time" @@ -36,8 +37,9 @@ func Parse(f wire.Frame) (Event, error) { // hit parses a hit event from a frame func hit(r *reader) Event { var ( - h = Hit{Target: r.entity()} - sw = r.varint() + h = Hit{Target: r.entity()} + sw = r.varint() + noDamage bool ) r.varint() // 0 so far h.Actor = r.entity() @@ -45,6 +47,8 @@ func hit(r *reader) Event { r.skip(1) // the actor's hit count h.Type = r.small() switch sw & 0xF { + case 0: + noDamage = true // a hit that deals none: Damage stays 0, and its varint is not sent case 4: case 6: h.Mods = r.u8() @@ -56,7 +60,9 @@ func hit(r *reader) Event { r.skip(4) // varies with the skill, not a float seq := r.u32() h.Scalar = r.varint() - h.Damage = r.varint() + if !noDamage { + h.Damage = r.varint() + } if sw&0x20 != 0 { n := r.varint() if n > uint32(r.left()) { @@ -75,14 +81,31 @@ func hit(r *reader) Event { return h } -// spawn parses a spawn event from a frame +// spawn parses a spawn event from a frame. +// +// A mask of two bytes comes before the flag since the game's patch of October 2026, and four bytes +// came before it. The NPC's id is at most three bytes, so its fourth is 00 in the layout that is +// right, and the one that is wrong finds a byte there that is not. func spawn(r *reader) Event { - s := Spawn{Entity: r.entity(), Mask: r.u32()} + s := Spawn{Entity: r.entity()} + if p := r.peek(9); p != nil && p[6] == 0 && p[8] != 0 { + s.Mask = uint32(r.u16()) + } else { + s.Mask = r.u32() + } if r.u8()&1 != 0 { return nil // a name follows; no fixture } s.NPC = r.npc() - r.skip(2) + r.skip(2) // 00 02 so far, 08 02 twice + s.Pos = r.pos() + return s +} + +// spawnStatic parses 34 36, the spawn of an entity that never moved or died in the recording, from a frame +func spawnStatic(r *reader) Event { + s := Spawn{Entity: r.entity(), Mask: uint32(r.u16())} + s.NPC = r.npc() s.Pos = r.pos() return s } @@ -129,11 +152,16 @@ func cast(r *reader) Event { c.Skill = r.skill() r.skip(2) c.Target = r.entity() - if r.left() < 16 { - return nil // the short self-cast form, without a position + c.Angle = r.f32() + if r.left() >= 12 { + c.Pos = r.pos() } - r.skip(4) - c.Pos = r.pos() + r.varint() // 10000 so far + if r.u8() != 1 { + r.bad = true + } + c.Duration = r.varint() + r.end() return c } @@ -178,19 +206,17 @@ func ping(r *reader) Event { return p } -// turn parses a lighter movement update, with no position, from a frame +// turn parses a lighter movement update, with no position, from a frame. +// +// The flag's low four bits each say that one more byte follows it, which of them it is not +// understood. Then two u16 headings, and a trailing 01 unless the flag's bit 0 is set. func turn(r *reader) Event { t := Turn{Entity: r.entity()} flag := r.u8() - r.skip(2) // changes with the entity's movement, not understood - if flag&0x08 != 0 { - r.skip(1) // one more byte, seen only when this bit is set - } + r.skip(bits.OnesCount8(flag & 0x0F)) t.Heading = r.u16() - if r.u16() != t.Heading { - r.bad = true // sent twice, always the same so far - } - if r.u8() != 1 { + t.Heading2 = r.u16() + if flag&1 == 0 && r.u8() != 1 { r.bad = true // 01 so far } r.end() @@ -230,6 +256,35 @@ func heading(r *reader, h Heading) Event { return h } +// stats parses an entity's attributes from a frame: a mask whose bit 0 says a group of u32 values +// follows, and bit 1 a group of u64 ones, each a count and then an id and a value that many times. +func stats(r *reader) Event { + s := Stats{Entity: r.entity()} + mask := r.u8() + if mask == 0 || mask&^3 != 0 { + r.bad = true + return nil + } + for bit, width := range [...]int{4, 8} { + if mask>>bit&1 == 0 { + continue + } + n := int(r.u8()) + if n*(1+width) > r.left() { + r.bad = true + return nil + } + for range n { + id, v := r.u8(), r.take(width) + var buf [8]byte + copy(buf[:], v) + s.Values = append(s.Values, Stat{ID: id, Value: binary.LittleEndian.Uint64(buf[:])}) + } + } + r.end() + return s +} + // zone parses a zone event from a frame func zone(r *reader) Event { if r.varint() != 0 { @@ -405,3 +460,246 @@ func lobbyPing(r *reader) Event { // unixMilli converts a Unix milliseconds timestamp to a time.Time in UTC. func unixMilli(ms int64) time.Time { return time.UnixMilli(ms).UTC() } + +// ref parses a message of an entity and nothing else from a frame +func ref(r *reader) Event { + e := Ref{r.entity()} + r.end() + return e +} + +// tagged parses an entity and the bytes after it from a frame +func tagged(r *reader) Event { + t := Tagged{Entity: r.entity()} + t.Data = append([]byte(nil), r.take(r.left())...) + return t +} + +// signal parses a message of one to four bytes from a frame +func signal(r *reader) Event { + var s Signal + if n := r.left(); n >= 1 && n <= 4 { + for i, b := range r.take(n) { + s.Code |= uint32(b) << (8 * i) + } + } else { + r.bad = true + } + return s +} + +// target parses a mob's change of target from a frame +func target(r *reader) Event { + t := Target{Entity: r.entity()} + if r.u8() != 0 { + r.bad = true + } + t.Target = r.entity() + r.end() + return t +} + +// combo parses a skill chain, 18 38, from a frame +func combo(r *reader) Event { + c := Combo{From: r.skill()} + if r.u8() != 1 { + r.bad = true + } + c.To = r.skill() + r.end() + return c +} + +// comboNext parses 19 38, the next skill of a chain, from a frame +func comboNext(r *reader) Event { + if r.u8() != 1 { + r.bad = true + } + c := Combo{To: r.skill()} + r.end() + return c +} + +// skillUse parses the player's skill use from a frame +func skillUse(r *reader) Event { + if r.u8() != 0 { + r.bad = true + } + s := SkillUse{Info: r.u16()} + s.Skill = r.skill() + r.end() + return s +} + +// windup parses the notice of a cast to come from a frame +func windup(r *reader) Event { + w := Windup{Actor: r.entity()} + if r.u8() != 0 { + r.bad = true + } + w.Skill = r.skill() + r.skip(2) // as Cast: a counter and 02 + w.Target = r.entity() + w.Angle = r.f32() + w.Pos = r.pos() + w.Duration = r.varint() + if r.u8() != 1 { + r.bad = true + } + r.end() + return w +} + +// engage parses a mob about to take the player as its target from a frame +func engage(r *reader) Event { + if r.u16() != 0 || r.u8() != 0 { + r.bad = true + } + e := Engage{r.entity()} + r.end() + return e +} + +// threat parses 31 38 from a frame +func threat(r *reader) Event { + if r.u16() != 0 { + r.bad = true + } + t := Threat{Entity: r.entity()} + t.Value = r.u16() + r.end() + return t +} + +// attributes parses the player's attribute table, 49 36, with no entity, from a frame +func attributes(r *reader) Event { + if r.u16() != 0 { + r.bad = true + } + return attributeTable(r, Attributes{}) +} + +// attributesOf parses an entity's attribute table, 4A 36, from a frame +func attributesOf(r *reader) Event { return attributeTable(r, Attributes{Entity: r.entity()}) } + +func attributeTable(r *reader, a Attributes) Event { + n := int(r.u8()) + if n*6+8 != r.left() { + r.bad = true + return nil + } + a.Values = make([]Attribute, n) + for i := range a.Values { + a.Values[i] = Attribute{ID: r.u16(), Value: int32(r.u32())} + } + r.skip(8) // zeros so far + r.end() + return a +} + +// height parses an entity's height from a frame +func height(r *reader) Event { + h := Height{Entity: r.entity()} + h.Z = r.f32() + r.end() + return h +} + +// effectAdded parses an effect put on an entity, 2A 38, from a frame +func effectAdded(r *reader) Event { return effect(r, false) } + +// effectChanged parses an effect's change, 2B 38, from a frame +func effectChanged(r *reader) Event { return effect(r, true) } + +func effect(r *reader, refresh bool) Event { + e := Effect{Entity: r.entity(), Refresh: refresh} + if !refresh && r.u8() != 1 { + r.bad = true + } + e.Kind = r.u8() + if e.Kind&^0x13 != 0 { + r.bad = true // a kind no recording has + } + e.Instance = r.varint() + e.ID = r.u32() + e.Value = r.u32() + r.skip(4) // 0, or FFFFFFFF with the permanent ones + e.Time = unixMilli(int64(r.u64())) + e.Source = r.entity() + r.skip(1) // 01 + if e.Kind&2 != 0 { + e.Skill = r.skill() + } + r.skip(1) // 00, or 02 on 2B 38 + if e.Kind&0x10 != 0 { + e.Pos = r.pos() + } + r.end() + return e +} + +// area parses a skill that lands, 03 38 and 0E 38, from a frame +func area(r *reader) Event { + a := Area{Actor: r.entity()} + flag := r.u8() // 00 on 03 38, 02 on 0E 38 + a.Target = r.entity() + a.Skill = r.skill() + r.skip(1) // a counter + a.Instance = r.u32() + a.Pos = r.pos() + if flag == 2 { + r.skip(7) + a.Pos2 = r.pos() + r.end() + } + return a +} + +// cutscene parses the start of a scene from a frame +func cutscene(r *reader) Event { + c := Cutscene{Kind: r.u32(), ID: r.u32()} + r.skip(8) // a u32 that changes, and a u32 of zeros so far + c.Pos = r.pos() + // A name, when there is one, is the last thing there: its length, its bytes, and 00. + for rest := r.p[min(r.i, len(r.p)):]; len(rest) > 2; { + n := int(rest[0]) + if n > 0 && len(rest) == n+2 && rest[len(rest)-1] == 0 { + c.Name = string(rest[1 : n+1]) + break + } + rest = rest[1:] + } + r.i = len(r.p) + return c +} + +// script parses a named trigger from a frame +func script(r *reader) Event { + s := Script{Flag: r.u8(), Kind: r.u8()} + s.Name = r.name() + return s +} + +// moveFlagged parses a move whose position follows a flag byte from a frame +func moveFlagged(r *reader) Event { + m := Move{Entity: r.entity()} + r.skip(1) + m.Pos = r.pos() + return m +} + +// moveLead parses a move whose position follows an id, and 00 01, from a frame +func moveLead(r *reader) Event { + m := Move{Entity: r.entity()} + r.skip(6) + m.Pos = r.pos() + return m +} + +// leadEntity parses 37 36, whose entity follows three bytes of zeros, from a frame +func leadEntity(r *reader) Event { + if r.u16() != 0 || r.u8() != 0 { + r.bad = true + } + return tagged(r) +} diff --git a/game/parse_test.go b/game/parse_test.go index 54c4d5f..f5d614c 100644 --- a/game/parse_test.go +++ b/game/parse_test.go @@ -273,14 +273,18 @@ func TestParseServersGroups(t *testing.T) { } } -// Both forms of the lead the flag's 0x08 bit chooses, with the one extra byte when it's set. +// The flag's low four bits each add a byte before the headings, and bit 0 set leaves out the +// trailing 01. The two headings are the same value, or a few units apart. func TestParseTurn(t *testing.T) { for _, tt := range []struct { name, payload string want game.Turn }{ - {"short lead", "64 02 11 22 34 12 34 12 01", game.Turn{Entity: 100, Heading: 0x1234}}, - {"long lead", "ac 02 08 33 44 55 cd ab cd ab 01", game.Turn{Entity: 300, Heading: 0xABCD}}, + {"flag 27", "c2 14 27 03 b1 1a c4 72 c4 72", game.Turn{Entity: 2626, Heading: 0x72c4, Heading2: 0x72c4}}, + {"flag 2f, with four bytes", "d0 e9 03 2f 03 35 09 03 ec 06 ec 06", game.Turn{Entity: 62672, Heading: 0x06ec, Heading2: 0x06ec}}, + {"flag 22, with a trailer", "e4 a9 02 22 0c 73 02 73 02 01", game.Turn{Entity: 38116, Heading: 0x0273, Heading2: 0x0273}}, + {"flag 26, with a trailer and two bytes", "c0 bf 02 26 02 11 ed 3a ed 3a 01", game.Turn{Entity: 40896, Heading: 0x3aed, Heading2: 0x3aed}}, + {"headings that differ", "90 67 0f 01 1a 2e ff 5b 29 6d 29", game.Turn{Entity: 13200, Heading: 0x295b, Heading2: 0x296d}}, } { e, err := game.Parse(wire.Frame{Opcode: 0x371D, Payload: unhex(t, tt.payload)}) if err != nil || e != tt.want { @@ -289,34 +293,154 @@ func TestParseTurn(t *testing.T) { } } -// 28/29 37 carry a position before the shared heading tail. -func TestParseHeadingWithPos(t *testing.T) { - e, err := game.Parse(wire.Frame{Opcode: 0x3728, Payload: unhex(t, - "64 04 00 00 80 42 00 00 a0 42 00 00 c0 42 00 00 90 42 00 00 a8 42 00 00 70 41 11 22 01")}) - want := game.Heading{Entity: 100, Pos: game.Pos{X: 64, Y: 80, Z: 96}, - Heading1: 72, Heading2: 84, Speed: 15} - if err != nil || e != want { +// Since October 2026 the mask before a spawn's NPC is two bytes, and four before it. +func TestParseSpawnMask(t *testing.T) { + for _, tt := range []struct { + name, payload string + want game.Spawn + }{ + {"mask of two bytes", "d0 e9 03 0c 20 00 f8 3a 29 00 00 02 80 2f b9 c7 00 24 55 47 00 f8 b8 46", + game.Spawn{Entity: 62672, NPC: 2702072, Mask: 0x200c, Pos: game.Pos{X: -94815, Y: 54564, Z: 23676}}}, + {"mask of four bytes", "a8 81 02 05 20 00 00 00 4a 0f 20 00 00 02 49 f3 d2 c6 e5 d0 00 c7 00 dc 8b 46", + game.Spawn{Entity: 32936, NPC: 2101066, Mask: 0x2005, Pos: game.Pos{X: -27001.643, Y: -32976.895, Z: 17902}}}, + } { + e, err := game.Parse(wire.Frame{Opcode: 0x3641, Payload: unhex(t, tt.payload)}) + if err != nil || e != tt.want { + t.Errorf("%s: got %+v, %v; want %+v", tt.name, e, err, tt.want) + } + } +} + +// 34 36 is a spawn too, with no flag before the NPC. +func TestParseSpawnStatic(t *testing.T) { + payload := unhex(t, "bb b4 02 01 00 e1 c8 10 00 80 8f 8b c7 00 8b 7b 47 00 ca bb 46") + want := game.Spawn{Entity: 39483, NPC: 1100001, Mask: 1, Pos: game.Pos{X: -71455, Y: 64395, Z: 24037}} + if e, err := game.Parse(wire.Frame{Opcode: 0x3634, Payload: payload}); err != nil || e != want { + t.Errorf("got %+v, %v; want %+v", e, err, want) + } +} + +// A hit whose switch nibble is 0 sends no damage. +func TestParseHitNoDamage(t *testing.T) { + payload := unhex(t, "ad 28 00 00 ad 28 a2 0f 00 00 01 02 ef 1a 06 00 01 00 00 00 90 4e 01 00") + want := game.Hit{Actor: 5165, Target: 5165, Skill: 4002, Type: 2, Scalar: 10000} + if e, err := game.Parse(wire.Frame{Opcode: 0x3804, Payload: payload}); err != nil || !reflect.DeepEqual(e, want) { t.Errorf("got %+v, %v; want %+v", e, err, want) } } -// 2A 37 has no position, and its lead before the shared tail varies in length. -func TestParseHeadingOnly(t *testing.T) { +// The short form of a cast has its angle and no position, whoever its target is. +func TestParseCastShort(t *testing.T) { for _, tt := range []struct { - name, lead string + payload string + want game.Cast }{ - {"short lead", "12"}, - {"long lead", "16 0d 05"}, + {"ad 28 00 92 3e c2 00 09 00 ad 28 08 b8 ab 43 90 4e 01 00", game.Cast{Actor: 5165, Target: 5165, Skill: 12730002, Angle: 343.437744140625}}, + {"ad 28 00 91 3e c2 00 0b 00 a0 f6 03 a1 8a 2c 43 90 4e 01 00", game.Cast{Actor: 5165, Target: 64288, Skill: 12730001, Angle: 172.54151916503906}}, } { - payload := unhex(t, tt.lead+" 00 00 80 42 00 00 a0 42 00 00 c0 42 11 22 01") - e, err := game.Parse(wire.Frame{Opcode: 0x372A, Payload: append(unhex(t, "64"), payload...)}) - want := game.Heading{Entity: 100, Heading1: 64, Heading2: 80, Speed: 96} - if err != nil || e != want { - t.Errorf("%s: got %+v, %v; want %+v", tt.name, e, err, want) + e, err := game.Parse(wire.Frame{Opcode: 0x3802, Payload: unhex(t, tt.payload)}) + if err != nil || e != tt.want { + t.Errorf("got %+v, %v; want %+v", e, err, tt.want) } } } +// 2F 37 is a move with more after the position. +func TestParseMove2F(t *testing.T) { + payload := unhex(t, "c8 dd 02 02 ba 9e 8b c7 3e 5d 97 47 00 8e be 46 00 00 00 00 00 00 00 00 00 00 00 00 00 85 3f 47 01") + e, err := game.Parse(wire.Frame{Opcode: 0x372F, Payload: payload}) + m, ok := e.(game.Move) + if err != nil || !ok || m.Entity != 44744 || m.X > -71000 || m.X < -72000 || m.Y < 77000 { + t.Errorf("got %+v, %v", e, err) + } +} + +// Stats' two groups, each with its count, and the mask that says which are there. +func TestParseStats(t *testing.T) { + for _, tt := range []struct { + name, payload string + want game.Stats + }{ + {"u32 only", "ad 28 01 01 03 90 5f 01 00", game.Stats{Entity: 5165, Values: []game.Stat{{ID: 3, Value: 90000}}}}, + {"u64 only", "fb d4 02 02 01 00 73 00 00 00 00 00 00 00", game.Stats{Entity: 43643, Values: []game.Stat{{ID: 0, Value: 115}}}}, + {"both", "ad 28 03 02 01 9d 03 00 00 03 a4 32 01 00 01 00 77 00 00 00 00 00 00 00", + game.Stats{Entity: 5165, Values: []game.Stat{{ID: 1, Value: 925}, {ID: 3, Value: 78500}, {ID: 0, Value: 119}}}}, + } { + e, err := game.Parse(wire.Frame{Opcode: 0x8D00, Payload: unhex(t, tt.payload)}) + if err != nil || !reflect.DeepEqual(e, tt.want) { + t.Errorf("%s: got %+v, %v; want %+v", tt.name, e, err, tt.want) + } + } +} + +// The guessed opcodes, read from frames of a recording from 10 October 2026. +func TestParseGuesses(t *testing.T) { + pos := game.Pos{X: -100938.4765625, Y: 54664.25, Z: 24089} + for _, tt := range []struct { + name string + op wire.Opcode + hex string + want game.Event + }{ + {"target", 0x3835, "d0 e9 03 00 ad 28", game.Target{Entity: 62672, Target: 5165}}, + {"target let go", 0x3835, "a0 f6 03 00 00", game.Target{Entity: 64288}}, + {"combo", 0x3818, "89 26 00 00 01 8a 26 00 00", game.Combo{From: 9865, To: 9866}}, + {"combo next", 0x3819, "01 8a 26 00 00", game.Combo{To: 9866}}, + {"skill use", 0x3801, "00 00 00 a1 0f 00 00", game.SkillUse{Skill: 4001}}, + {"skill use with info", 0x3801, "00 13 18 8c 26 00 00", game.SkillUse{Skill: 9868, Info: 0x1813}}, + {"engage", 0x3834, "00 00 00 d0 e9 03", game.Engage{Entity: 62672}}, + {"threat", 0x3831, "00 00 fb d4 02 7f 3e", game.Threat{Entity: 43643, Value: 0x3e7f}}, + {"height", 0x3746, "ad 28 00 32 bc 46", game.Height{Entity: 5165, Z: 24089}}, + {"ref", 0x363B, "9a ed 01", game.Ref{Entity: 30362}}, + {"signal", 0x383B, "00 00", game.Signal{}}, + {"tagged", 0x3638, "9a ed 01 ad 28 01", game.Tagged{Entity: 30362, Data: []byte{0xad, 0x28, 0x01}}}, + {"windup", 0x3809, "a0 f6 03 00 44 ae 12 00 01 02 ad 28 83 80 2e c3 53 48 b6 c7 86 85 55 47 f9 fc b8 46 f4 03 01", + game.Windup{Actor: 64288, Target: 5165, Skill: 1224260, Angle: -174.5019989013672, Duration: 500, + Pos: game.Pos{X: -93328.6484375, Y: 54661.5234375, Z: 23678.486328125}}}, + {"script", 0x8D15, "00 09 14 43 75 74 73 63 65 6e 65 5f 4c 5f 41 5f 51 4d 5f 31 30 30 31 00 d8 5f 01 00 00 00 00 00 01 00 00 00 00", + game.Script{Kind: 9, Name: "Cutscene_L_A_QM_1001"}}, + {"effect", 0x382A, "ad 28 01 11 05 12 27 00 00 88 13 00 00 00 00 00 00 96 8a 81 26 a1 01 00 00 ad 28 01 00 3d 25 c5 c7 40 88 55 47 00 32 bc 46", + game.Effect{Entity: 5165, Kind: 0x11, Instance: 5, ID: 10002, Value: 5000, Source: 5165, Pos: pos, + Time: time.UnixMilli(1791647386262).UTC()}}, + {"effect changed", 0x382B, "ad 28 11 05 12 27 00 00 7d 2d 02 00 00 00 00 00 ee a4 83 26 a1 01 00 00 ad 28 01 02 3d 25 c5 c7 40 88 55 47 00 32 bc 46", + game.Effect{Entity: 5165, Kind: 0x11, Instance: 5, ID: 10002, Value: 142717, Source: 5165, Pos: pos, Refresh: true, + Time: time.UnixMilli(0x1a12683a4ee).UTC()}}, + {"move 3C 37", 0x373C, "ad 28 00 3d 25 c5 c7 40 88 55 47 00 32 bc 46 00 00 b4 43 00 00 05 00", game.Move{Entity: 5165, Pos: pos}}, + } { + e, err := game.Parse(wire.Frame{Opcode: tt.op, Payload: unhex(t, tt.hex)}) + if err != nil || !reflect.DeepEqual(e, tt.want) { + t.Errorf("%s: got %+v, %v; want %+v", tt.name, e, err, tt.want) + } + } +} + +// 4A 36 is a count of ids and values, and eight bytes after. +func TestParseAttributes(t *testing.T) { + payload := unhex(t, "d8 18 03 1f 00 05 00 00 00 59 00 c4 09 00 00 48 f4 ff ff ff ff 00 00 00 00 00 00 00 00") + want := game.Attributes{Entity: 3160, Values: []game.Attribute{{ID: 0x1f, Value: 5}, {ID: 0x59, Value: 2500}, {ID: 0xf448, Value: -1}}} + if e, err := game.Parse(wire.Frame{Opcode: 0x364A, Payload: payload}); err != nil || !reflect.DeepEqual(e, want) { + t.Errorf("got %+v, %v; want %+v", e, err, want) + } + // 49 36 is the player's, and has no entity + self := unhex(t, "00 00 01 1f 00 05 00 00 00 00 00 00 00 00 00 00 00") + if e, err := game.Parse(wire.Frame{Opcode: 0x3649, Payload: self}); err != nil || !reflect.DeepEqual(e, game.Attributes{Values: []game.Attribute{{ID: 0x1f, Value: 5}}}) { + t.Errorf("got %+v, %v", e, err) + } + if _, err := game.Parse(wire.Frame{Opcode: 0x364A, Payload: unhex(t, "d8 18 03 1f 00 05 00 00 00 00 00 00 00 00 00 00 00")}); !errors.Is(err, game.ErrLayout) { + t.Errorf("a count of three with one value: %v", err) + } +} + +// The scene's name is the last thing in 21 36, when it has one. +func TestParseCutscene(t *testing.T) { + payload := unhex(t, "01 00 00 00 a1 86 01 00 6d 39 d2 00 00 00 00 00 3d 25 c5 c7 40 88 55 47 00 32 bc 46 64 25 bb b5 00 00 00 00 00 00 00 00 00 00 00 00 14 43 75 74 73 63 65 6e 65 5f 4c 5f 41 5f 51 4d 5f 31 30 30 31 00") + e, err := game.Parse(wire.Frame{Opcode: 0x3621, Payload: payload}) + c, ok := e.(game.Cutscene) + if err != nil || !ok || c.Name != "Cutscene_L_A_QM_1001" || c.ID != 100001 || c.Kind != 1 || c.Pos != (game.Pos{X: -100938.4765625, Y: 54664.25, Z: 24089}) { + t.Errorf("got %+v, %v", e, err) + } +} + func TestParseJoin(t *testing.T) { e, err := game.Parse(wire.Frame{Opcode: 0x390D, Payload: unhex(t, "00 00 00 06 09")}) if want := (game.Join{Server: 2310}); err != nil || e != want { @@ -361,10 +485,15 @@ func TestParseRejects(t *testing.T) { {"hit with switch nibble 6 cut in its three bytes", 0x3804, "c7 7c 06 00 f5 a3 02 e0 26 a8 00 00 02 00 00", game.ErrLayout}, {"hit with switch nibble 5", 0x3804, "c7 7c 05 00 f5 a3 02 e0 26 a8 00 00 02 00 00 00 00 00 00 00 00 00 00 00 00 01 00 00 00 90 4e 20 01 00", game.ErrUnread}, {"hit whose type does not fit a byte", 0x3804, "c7 7c 04 00 f5 a3 02 e0 26 a8 00 00 ac 02 4b ed 9f 41 01 00 00 00 90 4e 24 01 00", game.ErrLayout}, - {"turn cut short", 0x371D, "64 02 11 22 34 12 34 12", game.ErrLayout}, - {"turn whose heading is not repeated", 0x371D, "64 02 11 22 34 12 00 00 01", game.ErrLayout}, - {"turn whose trailer is not 01", 0x371D, "64 02 11 22 34 12 34 12 02", game.ErrLayout}, - {"turn with a byte too many", 0x371D, "64 02 11 22 34 12 34 12 01 00", game.ErrLayout}, + {"turn cut short", 0x371D, "64 02 11 34 12 34 12", game.ErrLayout}, + {"turn whose trailer is not 01", 0x371D, "64 02 11 34 12 34 12 02", game.ErrLayout}, + {"turn with a byte too many", 0x371D, "64 02 11 34 12 34 12 01 00", game.ErrLayout}, + {"turn with a trailer its flag leaves out", 0x371D, "64 03 11 22 34 12 34 12 01", game.ErrLayout}, + {"turn with fewer bytes than its flag adds", 0x371D, "64 07 11 22 34 12 34 12", game.ErrLayout}, + {"stats with no group", 0x8D00, "ad 28 00", game.ErrLayout}, + {"stats with a group its mask does not have", 0x8D00, "ad 28 04 01 03 90 5f 01 00", game.ErrLayout}, + {"stats claiming more values than bytes", 0x8D00, "ad 28 01 05 03 90 5f 01 00", game.ErrLayout}, + {"stats with a byte too many", 0x8D00, "ad 28 01 01 03 90 5f 01 00 00", game.ErrLayout}, {"heading too short for its tail", 0x372A, "64 12 00 00 80 42 00 00 a0 42 00 00 c0 42 11", game.ErrLayout}, {"heading whose trailer is not 01", 0x3728, "64 04 00 00 80 42 00 00 a0 42 00 00 c0 42 00 00 90 42 00 00 a8 42 00 00 70 41 11 22 02", game.ErrLayout}, {"heading with a byte too many", 0x372A, "64 12 00 00 80 42 00 00 a0 42 00 00 c0 42 11 22 01 00", game.ErrLayout}, @@ -373,7 +502,7 @@ func TestParseRejects(t *testing.T) { {"hit claiming more extra hits than bytes", 0x3804, "c7 7c 24 00 f5 a3 02 e0 26 a8 00 00 02 4b ed 9f 41 01 00 00 00 90 4e 24 7f 01 00", game.ErrLayout}, {"death with a byte too many", 0x3642, "f5 a3 02 00 03 00", game.ErrLayout}, {"death whose middle varint is not zero", 0x3642, "f5 a3 02 01 03", game.ErrLayout}, - {"cast without a position", 0x3802, "e4 72 00 d8 62 d6 00 0b 00 e4 72 6d d9 90 43 90 4e 01 00", game.ErrUnread}, + {"cast of the short form with a byte too many", 0x3802, "e4 72 00 d8 62 d6 00 0b 00 e4 72 6d d9 90 43 90 4e 01 00 00", game.ErrLayout}, {"cast end with a byte too many", 0x3806, "c7 7c e0 26 a8 00 01 0c 00", game.ErrLayout}, {"move 1B 37 whose flag adds a byte it does not have", 0x371B, "e4 72 05 03 2b d5 bc c6 01 6f 24 c7 6a 42 8c", game.ErrLayout}, {"spawn with a name", 0x3641, "a8 81 02 05 20 00 00 01 4a 0f 20 00 00 02 00 00 00 00 00 00 00 00 00 00 00 00", game.ErrUnread}, diff --git a/game/testdata/cast.bin b/game/testdata/cast.bin index 31df40ac2a99727c6cc1d3d17cf0d31f27cdaedb..4951bbe060688f4ed4f9269359bb5d6a3bef24ec 100644 GIT binary patch delta 52 zcmdnZ_=0i5Ze2+xi?tdIlk5&La5Ajb;MlR+d4eA!gA`bFB2bi@VZk@%gSHevQHCd delta 7 Ocmey)w3umwG9v&CVghpj diff --git a/game/testdata/hit.expect.txt b/game/testdata/hit.expect.txt index 7c8a984..f53ae92 100644 --- a/game/testdata/hit.expect.txt +++ b/game/testdata/hit.expect.txt @@ -7,3 +7,6 @@ 04 38 game.Hit {Actor:15943 Target:18777 Skill:11010000 Damage:16 Extra:[1] Type:2 Scalar:10000 Mods:2 Direction:0} 04 38 game.Hit {Actor:15943 Target:19980 Skill:11190000 Damage:26 Extra:[2 2 2] Type:3 Scalar:10000 Mods:2 Direction:0} 04 38 game.Hit {Actor:15943 Target:31355 Skill:11190000 Damage:36 Extra:[3] Type:2 Scalar:10000 Mods:0 Direction:0} +04 38 game.Hit {Actor:5165 Target:5165 Skill:4002 Damage:0 Extra:[] Type:2 Scalar:10000 Mods:0 Direction:0} +04 38 game.Hit {Actor:5165 Target:46100 Skill:9868 Damage:0 Extra:[] Type:2 Scalar:10000 Mods:0 Direction:0} +04 38 game.Hit {Actor:28476 Target:28476 Skill:1900710 Damage:0 Extra:[] Type:0 Scalar:10000 Mods:0 Direction:0} diff --git a/game/testdata/move.bin b/game/testdata/move.bin index 20432850cbcd064cc3eebf12f5d535e78a20ddef..aaece68895fad78da289406a4695619e7cfe414a 100644 GIT binary patch delta 78 zcmdnV*uXSlr@Oko`H8zsOuOcFAGeF0?#|G+&y4{YwA#Bfs)5xoTwB_G+=FSVJHshV FH2|;R6vF@j delta 6 NcmZo*+Q~R!CjbY~0)_wp diff --git a/game/testdata/move.expect.txt b/game/testdata/move.expect.txt index 74a8a28..0639f0e 100644 --- a/game/testdata/move.expect.txt +++ b/game/testdata/move.expect.txt @@ -6,3 +6,5 @@ 1C 37 game.Move {Entity:39705 Pos:{X:-25664.053 Y:-33089.8 Z:18074}} 1C 37 game.Move {Entity:14692 Pos:{X:-23952.59 Y:-42236.453 Z:17935.75}} 1C 37 game.Move {Entity:27239 Pos:{X:-24092.316 Y:-42056.7 Z:17949}} +2F 37 game.Move {Entity:44744 Pos:{X:-71485.45 Y:77498.484 Z:24391}} +2F 37 game.Move {Entity:44744 Pos:{X:-71499.67 Y:76292.56 Z:24421}} diff --git a/game/testdata/spawn.bin b/game/testdata/spawn.bin index afe9a06e04707777f68d56e72010f3709cd346f1..7fc54b077c04e352f82bbb8c7979cede482dcffb 100644 GIT binary patch delta 327 zcmcb>+{wP-0;6iaquGLQ%sd7R%I2C33{3M2wj957Db$_e-wrnhjaAMJpBP&iTPIGG zoZP_30>l#L+zgD242%p63~Yb77(UDGTF#K|Xm;TxGmiqp4=a!n4f;EeGpGQK_yIJc zLDrFh9nA=1ZU#1p5j!|iHngDy1f z_1p|hNKS~@GUYylqlww>Eli9I4^Id%H1u~LXXvhWXE?Rn4d_fTU}R+6l-H=X5aezk zIICeYwQa#^1rVE^fx*_qY|ct%7KYieq73Z~laH^_oZ-&UwjWuyM?PFP4@eioxs(S? GK&=3e`&UB% delta 7 OcmeBVzreiV0wVwmy#nO` diff --git a/game/testdata/spawn.expect.txt b/game/testdata/spawn.expect.txt index 1d73792..99c039d 100644 --- a/game/testdata/spawn.expect.txt +++ b/game/testdata/spawn.expect.txt @@ -2,3 +2,8 @@ 41 36 game.Spawn {Entity:19143 NPC:2101066 Mask:8196 Pos:{X:-25067.506 Y:-31636.988 Z:18143}} 41 36 game.Spawn {Entity:37365 NPC:2101066 Mask:8197 Pos:{X:-25659.373 Y:-33702.46 Z:18119}} 41 36 game.Spawn {Entity:25199 NPC:2290017 Mask:12292 Pos:{X:-23251.486 Y:-42852.617 Z:17884}} +41 36 game.Spawn {Entity:64288 NPC:2701091 Mask:12300 Pos:{X:-92385.24 Y:54738.82 Z:23679}} +41 36 game.Spawn {Entity:62672 NPC:2702072 Mask:8204 Pos:{X:-94815 Y:54564 Z:23676}} +41 36 game.Spawn {Entity:54379 NPC:2701693 Mask:12301 Pos:{X:-71380.51 Y:65140.023 Z:24014}} +34 36 game.Spawn {Entity:39483 NPC:1100001 Mask:1 Pos:{X:-71455 Y:64395 Z:24037}} +34 36 game.Spawn {Entity:54428 NPC:1400219 Mask:4 Pos:{X:-75521.055 Y:77907.34 Z:24515}} diff --git a/game/testdata/stats.bin b/game/testdata/stats.bin new file mode 100644 index 0000000000000000000000000000000000000000..3d5da55a66dc8f85fc389c98e4f726ab200d0608 GIT binary patch literal 108 zcmXqH=v}M9%))qwm4Sf+L~t!=V`Sg~5ljr_3=qH$V~BuMGcvInlrk`|f#mqX0*tHy bhZz`vlE1GoF)=a}L$wKmB!PUOVlV&zy minBundle && (len(b) < 3 || b[2] == 0xFF) // a flagged bundle } if f.knownOnly { - return opcode(b).Known() + return opcode(b).listed() } return inFamily(b[1]) } diff --git a/wire/opcode.go b/wire/opcode.go index 2d724e1..439c91f 100644 --- a/wire/opcode.go +++ b/wire/opcode.go @@ -48,14 +48,38 @@ var knownBits = func() (bits [1 << 10]uint64) { // known lists the opcodes the lock counts and KnownOnly keeps var known = [...]Opcode{ 0x3804, 0x3805, 0x3802, 0x3806, 0x3822, 0x382A, 0x382B, 0x382C, 0x3835, 0x3847, - 0x3603, 0x361A, 0x3623, 0x3633, 0x3640, 0x3641, 0x3642, 0x3645, 0x3646, 0x3647, 0x3649, 0x364A, + 0x3603, 0x361A, 0x3623, 0x3633, 0x3640, 0x3641, 0x3634, 0x3642, 0x3645, 0x3646, 0x3647, 0x3649, 0x364A, 0x3600, // Server tick - 0x371A, 0x371B, 0x371C, 0x371D, 0x3728, 0x3729, 0x372A, + 0x371A, 0x371B, 0x371C, 0x371D, 0x3728, 0x3729, 0x372A, 0x372F, 0x8D00, 0x8D04, 0x8D21, 0x921B, 0x9200, 0x920D, 0x3901, 0x3903, 0x3906, 0x3909, 0x390B, 0x390D, 0x390F, // Lobby opcodes } +// guessed lists the opcodes package game reads on a guess. A decoder that keeps only known opcodes +// keeps them too, but the lock does not count them: a guess more in the list would make noise +// look like the game's frames sooner. +var guessed = [...]Opcode{ + 0x3618, 0x3621, 0x3631, 0x3635, 0x3637, 0x3638, 0x363A, 0x363B, 0x364B, 0x364E, + 0x371E, 0x3721, 0x3722, 0x3723, 0x3724, 0x372B, 0x372E, 0x373C, 0x3741, 0x3742, 0x3746, + 0x3801, 0x3803, 0x3809, 0x380C, 0x380E, 0x3818, 0x3819, 0x3831, 0x3834, 0x383B, 0x383D, + 0x8D03, 0x8D15, 0x8D41, 0x8D43, 0x8D52, 0x8D6D, 0x8D91, 0x8D93, +} + +// Guessed reports whether o is one package game reads on a guess, and the lock does not count. +func (o Opcode) Guessed() bool { return guessedBits[o>>6]&(1<<(o&63)) != 0 } + +// guessedBits is guessed as a bitmap, one bit an opcode. +var guessedBits = func() (bits [1 << 10]uint64) { + for _, o := range guessed { + bits[o>>6] |= 1 << (o & 63) + } + return bits +}() + +// listed reports whether o is known, or guessed: what KnownOnly keeps. +func (o Opcode) listed() bool { return o.Known() || o.Guessed() } + // inFamily reports whether b, an opcode's high byte, is one of the families. Unless KnownOnly // is set, an opcode in a family is plausible. Every known opcode is in one. func inFamily(b byte) bool {