Skip to content

feat(game): read the October 2026 layouts, and name the remaining opcodes - #3

Open
hanios123 wants to merge 4 commits into
nuriland:mainfrom
hanios123:feat/october-patch-opcodes
Open

hanios123 wants to merge 4 commits into
nuriland:mainfrom
hanios123:feat/october-patch-opcodes

Conversation

@hanios123

@hanios123 hanios123 commented Oct 10, 2026 •

Copy link
Copy Markdown

Decodes what a 500 s capture holds (16,111 server messages), taken in October 2026 from the game's Steam client: decoded 11,353 → 15,613, failed 945 → 0. The branch also carries the Account/Join, Turn and Heading commits, which are not on main yet.

Fixed (checked against every sample in the capture)

Opcode Change
1D 37 Turn The flag's low four bits each add one byte before the two headings; the trailing 01 is sent only with flag bit 0 clear. The old layout failed 945 of 980. New Heading2.
41 36 Spawn The mask is two bytes since the October patch, four before it. Both are read. Positions match the entity's later Move.
04 38 Hit Switch nibble 0 sends no damage varint (Damage is 0, never otherwise).
02 38 Cast The short form is read; Angle and Duration added. The older fixtures decode with the same tail.

New

  • 00 8D Stats: an entity's attributes, a mask then u32 and u64 groups. 1392/1392 parse.
  • 34 36 Spawn (entities that never moved), 2F 37 Move.

Guessed (game/guess.go)

Target, Combo, SkillUse, Windup, Engage, Threat, Attributes, Height, Effect, Area, Cutscene, Script, and the generic Ref, Tagged, Signal. Each doc comment says what the guess rests on. The other types in the capture are named from their family and not read. a2k stats marks them guessed, as opposed to known.

wire

Guessed opcodes sit in a guessed list of their own: KnownOnly keeps them, the lock does not count them. Adding them to known doubled the noise frames that look like the game's (go run ./internal/score -n 100 -noise 5: 2869 vs 1419 known frames); with the separate list it is 1478, still 0 locks.

Tests

  • Fixtures are frames of the capture. The synthetic Turn and Cast ones, whose layouts the capture contradicts, are replaced, and TestFight's casts now carry a tail.
  • New real-frame tests for each decoder; TestReadsKnown checks the guessed and known lists stay apart.

Notes

  • The capture was taken from the Steam client of the game, and is not included. The fixtures hold only game payloads (entity ids, positions, skill ids), no account or character data.
  • The guessed names come from one capture and may change when another one disagrees.

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.
…sition

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.
…etimes 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.
…odes

Read from a 500 s capture (16111 server messages): decoded 11353 -> 15613,
failed 945 -> 0.

Fixed, each checked against every sample in the capture:
- 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 capture 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 capture; the synthetic Turn and Cast ones, whose
layouts the capture contradicts, are replaced.
@nuriland

Copy link
Copy Markdown
Owner

Hey, thanks for putting this together! I'm currently occupied with other projects, so I haven't had a chance to fully take a look at your PR, but I'm going to get to it eventually. Meanwhile, I'm open to ideas on how to make it simpler to register opcodes.

I don't think guess should be an interface the lib supports. There's already enough moving elements to their protocol that exposing opcodes we do not have confirmed would just contribute to the library being more unstable than it already is.

// 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{

https://github.com/nuriland/a2kit/pull/3/changes#diff-77b24c31b04b997b3f6057b4c8f88a0bde68fc2161f4612503fbf6490b0cac6aR60-R62

@hanios123

Copy link
Copy Markdown
Author

Hey, thanks for putting this together! I'm currently occupied with other projects, so I haven't had a chance to fully take a look at your PR, but I'm going to get to it eventually. Meanwhile, I'm open to ideas on how to make it simpler to register opcodes.

I don't think guess should be an interface the lib supports. There's already enough moving elements to their protocol that exposing opcodes we do not have confirmed would just contribute to the library being more unstable than it already is.

// 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{

https://github.com/nuriland/a2kit/pull/3/changes#diff-77b24c31b04b997b3f6057b4c8f88a0bde68fc2161f4612503fbf6490b0cac6aR60-R62

Hi @nuriland, thanks for taking the time to review this PR and share your feedback!

Regarding the guess interface, your argument is totally valid. I wanted to bring this up so we can collaborate on confirming these guesses.

What do you think about creating a md doc file to track cases where an opcode has been captured but its status is still unknown? Once we confirm each case, we can open a PR to update its status accordingly.

This way, we can keep track of which opcodes have been confirmed and documented, and which ones still need further investigation or if you have a different idea to address this il be happy to hear it.

I'll adjust my PR based on your next feedback and suggestions.

@nuriland

nuriland commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

I just pushed some of my leftover work into the main tree. There's a small overlap with your PR, sorry about that. I'd focus the PR on the known, but not labeled, opcodes. I have a bunch of work queued up to improve the DX for labelling opcodes in the future.

Keep the pos changes and the lobby improvements. Those are good. The guessed opcodes should be removed from the dispatch loop and moved to a tracked Markdown list of opcodes to label in the future.

I'll give some thought to managing matrices of test data. Right now the game protocol is dependant on individual recordings, which override eachother. That makes it hard to review/accept feature PRs, which improve the lexer or the instruction dispatch loop. There's no source of truth or spec to manage. All of this will improve in future versions. Thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants