Open Tibia server in Rust, protocol 10.98, client target OTClient Redemption (mehah/otclient).
Reference spec: TFS 1.4.2 at reference/tfs/ (read-only — never edit, never port line-by-line).
Read this file first in every session. Repo layout: the OTClient test client lives in
../client/; this Rust workspace is the server. Run every command below from insideserver/. The server binary isoxidia.
- Milestone: M6 ✅ chat complete and accepted live. M6.1 (stairs / floor changes) ✅ accepted live — underground floor-change desync fixes (teleport sloped stairs/ladders + boundary mover re-add) landed and validated. M7 ✅ combat core + PvP melee and M7.1 ✅ combat polish (death→logout, PZ badge, blood) — both accepted live. M8 ✅ persistence + accounts + outfit change/persist, accepted live (load on login, save on logout; login never stacks on an occupied tile). M9 ✅ ground items + look-at, accepted live (examine items/creatures, TFS item text, real OTBM stack counts, GM debug). M10.1 ✅ ground items dynamic + move-thing, accepted live (copy-on-write tile overlay,
0x78ground→ground move with stack merge/split, faithful throw-range + line-of-sight, newest-item-on-top) — first slice of the M10 inventory chain; M10.2/M10.3 next. M5 ✅ presence, M4 ✅ walk. M6.2 (ladders/holes, use-driven) is folded into M11 — it is script-driven (teleport.luaonUse), not a data milestone, so it ships on the Lua runtime. Auto-walk remains deferred. - Build:
cargo buildclean,cargo testgreen (workspace),cargo clippy --all-targets -- -D warningsclean. - Toolchain: Rust 1.96, edition 2024,
#![forbid(unsafe_code)]in every crate. - Accepted (M1): real OTClient Redemption (protocol 1098) connects to
127.0.0.1:7171withtest/testand shows the MOTD + character list. M1 acceptance criterion fully met. - Accepted (M2):
cargo run -p formats --example mapinfoparses the realitems.otb(v3.57, 26 282 items) andforgotten.otbm(2048×2048, 340 594 tiles, 429 031 items, 5 towns) — full tree walk, no unknown nodes/attrs. - Accepted (M3): real OTClient Redemption enters the game on port 7172 and renders Test Knight standing on the real Thais temple ground (
forgotten.otbm), with stats (HP 150, Soul 100, Cap 400, Level 1) shown. A keep-alive ping holds the session — no more 30 s timeout.
Full roadmap (ROI-ordered, with rationale and ship gates):
docs/superpowers/specs/2026-06-06-roadmap-to-production.md.
Architecture is locked: Rust core + embedded Lua (mlua) for mutable content
(spells, monster behaviors, NPC dialogue, quests); static data in TOML/RON; everything
performance-critical or stable stays native Rust.
| # | Goal | State |
|---|---|---|
| M0 | Skeleton: workspace compiles, tests green, server listens on 7171/7172, logs connections | ✅ done |
| M1 | Login server: framing, Adler-32, RSA, XTEA, NetworkMessage, login parse, char list, sniff tool | ✅ done |
| M2 | Formats: .otb + .otbm parsers, mapinfo example |
✅ done |
| M3 | Enter game: game handshake (challenge), player load, initial packet sequence, render map | ✅ done |
| A | Living World → pre-alpha #1 | |
| M4 | Walk (core): visible creature, directional + diagonal walk, map slices, collision, turn (floor changes & auto-walk deferred) | ✅ done |
| M5 | Multiplayer presence: spectator / known-creatures system, broadcast movement | ✅ done |
| M6 | Chat: say / whisper / yell + default channel | ✅ done |
| M6.1 | Floor changes & stairs (walk-driven): items.xml loader (hasHeight + floorChange dir), tile vertical semantics, walk up/down in do_move, 0xBE/0xBF move-up/down, underground (z≥8) viewport + ±2 visibility band |
✅ done |
| M6.2 | Ladders & holes (use-driven) — deferred to M11: the behavior is script-driven (teleport.lua onUse), not data; ladders/grates carry no items.xml attribute. Belongs on the Lua runtime, not hardcoded in Rust. Research: docs/superpowers/specs/2026-06-07-m6.2-ladders-design.md |
⏸️ → M11 |
| M7 | Combat core + PvP melee: damage, HP sync, death, respawn, protected zones | ✅ done |
| M7.1 | Combat polish: death→logout flow (relog at temple, save-on-death), protection-zone client badge (ICON_PIGEON), blood-hit effect fix | ✅ done |
| M7.2 | Combat polish 2 — stop-attack bug + miss/blocked hit effects: (a) ✅ accepted live — handle inbound 0xBE cancelMove → clear the fight. ESC clears the client's red attack square locally and sends 0xBE; the server never handled it (drained as an unknown opcode), so the combat tick kept swinging with no visible target. Fix: reader_loop 0xBE arm → set_target(id, 0) (faithful subset of TFS playerCancelAttackAndFollow; no follow/auto-walk yet). 0xBE is direction-overloaded — outbound it is the floor-change-up slice, so the inbound handler is namespaced by direction. (b) ⬜ TFS-faithful hit-feedback effects: CONST_ME_POFF (3) on a full miss (BLOCK_DEFENSE / 0-damage roll), CONST_ME_BLOCKHIT (10) on armor-absorbed hits (BLOCK_ARMOR), CONST_ME_DRAWBLOOD (1) only on real damage — today DRAWBLOOD fires even on a 0 roll. Miss-puff shippable now; armor branch depends on M10.2 (real armor values). Research: TFS protocolgame.cpp parseCancelMove, game.cpp Game::playerCancelAttackAndFollow/Game::combatBlockHit |
🔄 |
| M8 | Persistence + accounts: per-account characters, saved position/stats/outfit (load on login, save on logout via unbounded save channel); outfit change + persist (0xD2 request → 0xC8 window, 0xD3 set → apply + 0x8E broadcast); login never stacks on an occupied tile (free_spawn_near) |
✅ done |
| M8.0a | Graceful shutdown save (persistence robustness) — ✅ accepted live: before M8.0a, saves fired only on clean logout/death, so killing the server with players still online reverted them to their last clean save (default outfit + temple spawn) on next login. Fix: Command::Shutdown → Game::save_all emits a SaveRecord per online player (no logout/broadcast); WorldHandle::shutdown_and_save acks once records are queued; the actor then drops save_tx, closing the FIFO save channel so the DB drain task flushes before exit. main traps Ctrl+C and SIGTERM (shutdown_signal). No periodic autosave yet (optional next step) |
✅ done |
| M8.1 | PvP justice — PK skull system: white skull on first unprovoked attack (whiteSkullTime 15 min) + yellow skull shown relationally to the victim; unjustified kills (victim was SKULL_NONE, not in war) count as frags → red skull (killsToRedSkull 3) / black skull (killsToBlackSkull 6); frag decay (timeToDecreaseFrags 24 h, checkSkullTicks); skull byte in AddCreature + sendCreatureSkull update; getSkullClient relational coloring. Depends on M7 (kills) + M8 (persist skull state + frag timestamps). Research: TFS const.h Skulls_t, player.cpp (addUnjustifiedDead/checkSkullTicks), config.lua.dist |
⏸️ deferred (non-priority) |
| B | Items & Inventory | |
| M9 | Ground items, stacks, look-at: examine (0x8C/0x8D) with TFS-faithful item text (name/article/plural/description/weight) + real OTBM stack counts + creature look; GM debug (item id + position). Static item rendering already shipped in M4/M6.1 |
✅ done |
| M10 | Inventory & equipment — split into a dependency chain (M10.1/M10.2/M10.3), like M6.1/M7.1 | 🔄 |
| M10.1 | Ground items dynamic + move-thing: mutable per-tile overlay (copy-on-write) + 0x78 ground→ground move, stack merge/split, faithful throw-range + line-of-sight (canThrowObjectTo/isSightClear), spectator broadcast (0x6A/0x6B item forms, 0x6C remove). Moved item renders on top (TFS front-insert into the down-items) |
✅ done |
| M10.2 | Inventory & equipment slots: equip/unequip, pickup/drop (ground↔inventory), weight/cap enforcement, persist inventory | ⬜ |
| M10.3 | Containers (backpack): open/close + nested + move in/out, plus use-to-open (0x82 use opens a container). Rest of use (levers/keys/runes/potions) → M11/Lua |
⬜ |
| C | Scripting | |
| M11 | Lua runtime (mlua): hot-reloadable content hooks (onUse/onStepIn/onSay/…). Includes use-driven floor changes (M6.2: ladders/holes via teleport.lua onUse) — see docs/superpowers/specs/2026-06-07-m6.2-ladders-design.md |
✅ done |
| D | PvE → pre-alpha #2 | |
| M12 | Creatures & monsters: spawns, AI, A* pathfinding | ⬜ |
| M13 | Loot & corpses | ⬜ |
| M14 | Skills, XP, levels, vocations | ⬜ |
| M15 | Spells, runes, conditions | ⬜ |
| E | Social & Economy | |
| M16 | NPCs: dialogue (Lua) + buy/sell | ⬜ |
| M17 | Depot, bank, money | ⬜ |
| M18 | Parties: shared XP | ⬜ |
| M19 | Guilds + guild channel | ⬜ |
| F | World Systems | |
| M20 | Houses | ⬜ |
| M21 | Quests | ⬜ |
| M22 | Market | ⬜ |
| M23 | Guild war systems: war declarations, war PvP rules, war/PZ exceptions (basic PK skulls/frags moved to M8.1) | ⬜ |
| G | Production Hardening 🏁 | |
| M24 | GM/admin tools | ⬜ |
| M25 | Persistence robustness | ⬜ |
| M26 | Account management (in-protocol creation, security) | ⬜ |
| M27 | Ops & stability: metrics, logging, rate-limit, reconnection, load test | ⬜ |
| M28 | Configurability & deploy | ⬜ |
Ship gates: pre-alpha #1 after M8 (walk + chat + PvP, persisted) · pre-alpha #2 after M15 (full PvE loop) · production after M28.
../client/ OTClient Redemption test client (C++) + downloads — NOT the server
server/ this Rust workspace (Oxidia)
crates/
net/ tokio listener + connection lifecycle (M0: accept+log; M1: framing)
protocol/ NetworkMessage, RSA, XTEA, Adler-32, packets (zero game logic)
formats/ .otb / .otbm parsers (pure, parse from &[u8])
world/ tile grid, single authoritative game loop over channels
persistence/ sqlx + sqlite accounts/players (sqlx wired in M1)
server/ binary: TOML config, tracing, wiring
config/server.toml ports, world name, db path, log filter
reference/tfs/ TFS 1.4.2 (gitignored, re-clone if missing)
Re-clone reference if absent:
git clone --depth 1 --branch v1.4.2 https://github.com/otland/forgottenserver reference/tfs
cargo build && cargo test
RUST_LOG=info cargo run -p server -- config/server.toml # binary name: oxidia- ✅
protocol: NetworkMessage reader/writer (LE) —message.rs, round-trip + EOF tests. - ✅
protocol: Adler-32 (adler.rs, canonical vectors), XTEA 32-round (xtea.rs, validated against an independent textbook XTEA oracle + round-trip), RSA raw modpow (rsa.rs,num-bigint-dig, bundled OpenTibia key, decrypt validated against public-exponent oracle). - ✅ Framing —
net::frameowns[u16 LE len][bytes]socket I/O (read_frame/write_frame,MAX_FRAME);protocol::frameowns the 4-byte LE Adler-32 checksum layer (checksummed/verify). XTEA-decrypt-after-handshake wiring lands with step 4's connection state. - ✅ Login packet parse —
protocol::login::parse(payload, &RsaPrivateKey) -> LoginRequest{os,version,xtea_key,account,password}. RSA block decrypts to[u8 0][u32x4 key][string account][string password]. Addedrsa::encrypt_open_tibia_public(client-side, for tests + sniff tool). - ✅
persistence: sqlx sqlite (Store),accounts/playersschema (migrations/0001_init.sql),authenticate+ idempotentseed_test_account_if_empty(test accounttest/test). - ✅ Login response —
protocol::charlist:CharacterList::encode(MOTD 0x14, session key 0x28, char list 0x64, single world, premium trailer) +build_error(0x0B ≥1076 else 0x0A). - ✅ Wiring —
net::serve_with(proto, addr, handler)(transport stays decoupled);server::login_service::handle_loginties framing + parse + auth + response, XTEA-encrypts the reply.main.rsopens the DB, seeds, serves login via the handler. - ✅
sniff+probeexamples (crates/server/examples/): login-aware MITM hexdumps raw + decrypted frames;probeis a minimal client. Integration testlogin_service::testsreplays a built login end-to-end (valid → char list, bad → error). Verified live over real sockets through the proxy.
Acceptance: protocol proven correct against the probe client (MOTD + char list). Remaining: point the real OTClient Redemption at the server. To run the proxy chain: server on an alt login_port (e.g. 7271), cargo run -p server --example sniff -- 127.0.0.1:7171 127.0.0.1:7271, then cargo run -p server --example probe.
- ✅
formats::node— generic OTB node-tree reader.parse_tree(&[u8]) -> Node{kind, props, children}. MarkersSTART 0xFE/END 0xFF/ESCAPE 0xFD; props returned un-escaped. Validated against the realforgotten.otbm. - ✅
formats::props—PropReaderLE cursor (read_u8/u16/u32/string/skip,remaining); mirrors TFSPropStream(string =u16len + bytes). - ✅
formats::otb—parse(&[u8]) -> ItemsOtb{major,minor,build, items: Vec<ItemType{group,flags,server_id,client_id}>}. Root version block + per-item attribute records. Realitems.otb= v3.57, 26 282 items. - ✅
formats::otbm—parse(&[u8]) -> OtbmMap{width,height,major_items,minor_items,description,spawn_file,house_file, tiles, towns, waypoints}. Walks TILE_AREA→TILE/HOUSETILE (inline + child items), TOWNS, WAYPOINTS. Real map parses fully. - ✅
mapinfoexample (crates/formats/examples/mapinfo.rs): loads both, prints versions/dims/file-refs/tile+item counts/per-floor distribution/town list. M2 acceptance criterion.
Run: cargo run -p formats --example mapinfo [items.otb] [map.otbm] (defaults to the bundled reference files).
Design + plan: docs/superpowers/specs/2026-06-06-m3-enter-game-design.md, docs/superpowers/plans/2026-06-06-m3-enter-game.md.
- ✅
protocol::challenge— encode0x1F[u32 ts][u8 random](checksummed, NOT XTEA — first server→client packet). - ✅
protocol::game_login—parse(payload, &RsaPrivateKey) -> GameLoginRequest. Game packet =[u8 0x0A id][u16 os][u16 version][skip 7][128-byte RSA block]; block =[u8 0][u32x4 xtea][u8 gamemaster][string sessionKey][string charName][u32 ts][u8 rnd].sessionKey=account\npassword\ntoken\ntokenTime(strict 4-part).build_requesttest helper. - ✅
protocol::map_description—0x64viewport (18×14, floors 7→0). Exact port of TFSGetMapDescription/GetFloorDescriptionskip-encoding;GroundSourcetrait keeps it map-agnostic. Round-trip tested against an OTClient-faithful decoder. - ✅
protocol::enter_world— burst encoders:self_info 0x17(29 B),pending 0x0A,enter_world 0x0F,stats 0xA0(53 B),skills 0xA1,world_light 0x82,creature_light 0x8D,empty_inventory 0x79×11,basic_data 0x9F,icons 0xA2,magic_effect 0x83,extended 0x32. - ✅
world::map::StaticMap(immutable ground lookupserver_id→client_id+ town-temple spawn, implGroundSource) +world::game::GameWorld(tokio actor over mpsc/oneshot, owns the player registry; map shared viaArc). - ✅
server::game_service—handle_game: challenge → parse → echo+version validate → enableXTEA →0x32(OTClient) →world.login→ burst.main.rsloadsitems.otb+forgotten.otbm, spawns the world, serves 7172 viaserve_with. Integration replay test overtokio::io::duplex. - ✅ Accepted live — real OTClient renders the player on the Thais temple ground. Two bugs fixed during acceptance: the challenge frame needed the inner
[u16 length](TFSonConnect:429); and the session must stay open with a keep-alive ping (0x1Devery 10 s of silence) or the client times out after 30 s.
Burst order (one XTEA frame): 0x17, 0x0A, 0x0F, 0x64 map, 0x83, 0x79×11, 0xA0, 0xA1, 0x82, 0x8D, 0x9F, 0xA2.
Deferred to M4: per-connection random challenge (M3 uses a fixed ts/rnd — functionally fine since the client echoes it back, but no replay protection); items/creatures on tiles; underground floor walk (z≥8); real player persistence (M3 spawns every char at the temple).
Design + plan: docs/superpowers/specs/2026-06-06-m4-walk-design.md, docs/superpowers/plans/2026-06-06-m4-walk.md. Scope: core walk on one floor + visible creature. Pure request/response over the world actor (no broadcast — single player; presence is M5).
- ✅
world::Direction(wire bytes N0 E1 S2 W3 SW4 SE5 NW6 NE7,delta) +Position::offset. - ✅
world::map::StaticMapwalkability — ablockedset derived at load from theitems.otbFLAG_BLOCK_SOLID(bit 0);is_walkable(pos)= has ground AND not blocked. - ✅
world::game—Move/Turnactor commands,MoveResult { outcome: Moved{from,to}|Blocked, facing };PlayerStategains a mutabledirection(spawn faces South). - ✅
protocol::creature— byte-faithfulAddCreature/AddOutfitport (1098).0x0061unknown /0x0062known forms; outfit, light,speed/2, shields, guild-emblem (unknown only), mark, helpers, walkthrough. - ✅
protocol::map_description— render creatures in the0x64(spliced after the ground item); refactor into a sharedget_map_description;encode_slicefor the directional row/column strips. - ✅
protocol::walk—creature_move 0x6D,cancel_walk 0xB5,creature_turn 0x6B, andwalk_update(0x6D + slices; independent y/x blocks so a diagonal emits both). - ✅
server::game_service— render the player in the enter-world burst;run_sessionnow decrypts each frame and dispatches walk (0x65-0x68,0x6A-0x6D) / turn (0x6F-0x72);Moved→walk_update,Blocked→cancel_walk. Integration replay walks east and asserts a0x6Dcomes back. - ✅ Accepted live — real OTClient renders the Test Knight with its outfit on the Thais temple ground and walks it around with arrow keys; walls stop it. Fixed during review: a bad-checksum frame now drops instead of killing the session.
Deferred (later slice): floor changes / stairs / underground (z≥8) walk → now scoped as M6.1 (stairs) + M6.2 (ladders/holes); auto-walk / click-to-move pathfinding; diagonal corner-cut blocking; real player persistence; walkthrough byte fidelity (self currently 0x00).
Design + plan: docs/superpowers/specs/2026-06-06-m5-presence-design.md, docs/superpowers/plans/2026-06-06-m5-presence.md. Scope: full presence — login appear, walk/turn broadcast, logout/disconnect remove, viewport in/out. Architecture chosen after verifying TFS 1.4.2 and improving on it (see README "Why Rust over a C++ port").
- ✅
protocol::tile_creature—add_tile_creature 0x6A([0x6A][pos][stackpos][creature thing]) andremove_tile_thing 0x6C([0x6C][pos][stackpos]), byte-faithful round-trip tests. - ✅
world::gamerewritten to unified push: the actor is the single builder of all outbound packets.PlayerStategainsoutfit,push_tx: mpsc::Sender<Vec<u8>>,known: HashSet<u32>.Commanddrops the Move/Turn reply channels and gainsLogout;LoginreturnsLoginAck { snapshot, others }. Spectators = iterate-all behindspectators(pos, exclude)(swappable to a quadtree later).introduce()owns the 0x61-full/0x62-short decision via the known-set.push()is non-blocking (try_send+ reap) so the loop never stalls on a slow client. - ✅
server::game_service— per-session push pipeline:handle_gametakes the stream by value (Send + 'static), splits it, spawns a writer task that greedily coalesces queued plaintext payloads into one XTEA frame and pings; a plain reader loop decodes inbound walk/turn into fire-and-forget commands. Reader/writer race viaselect!so a dead writer can't strand a blocked reader. (A singleselect!overread_frame+ the channel was rejected as a cancel-safety bug.) - ✅ Accepted live — two OTClients: each sees the other spawn (teleport puff), walk, turn, and poof out on logout; no desync. Spectators get the teleport effect on both login and logout (the logout puff is a deliberate polish over TFS, which removes silently).
Stackpos invariant (critical, found in final holistic review): 0x6A/0x6C are position+stackpos packets (no id-form for add), while 0x6D/0x6B use the id-form. StaticMap::creature_stackpos is a static per-tile value, so two creatures on one tile would collide on add/remove → desync. Fix: keep ≤1 creature per tile — do_move rejects a creature-occupied destination (collision) and login uses free_spawn() (nearest free tile when the temple is taken). Under that invariant the static stackpos is always correct. Co-occupancy has no path in M5 (logins serialize through the single actor; movement is blocked both ways; no teleport/summon).
Deferred (YAGNI): quadtree/sectored-grid spectators; known-set eviction cap (1300); multi-floor spectator band (±2 underground) — now done in M6.1; 0x6A/0x6C stackpos≥10 id-form; proactive socket close on backpressure kick.
Design + plan: docs/superpowers/specs/2026-06-07-m7-combat-design.md,
docs/superpowers/plans/2026-06-07-m7-combat.md. Scope: PvP melee, end
to end — 0xA1 attack target, 2 s auto-swing timer, TFS skill-based
fist damage, health-bar (0x8C) + self-stats (0xA0) sync, death window
(0x28), and temple respawn — with protection-zone attack rejection (0xB4).
Architecture: a single global combat tick (CombatTick command on the
existing actor mpsc) drives all in-progress fights, preserving the "one
writer, no locks" model. Pure feeders (wt-data damage math, wt-proto
combat packets) merged to main ahead of this spine.
- ✅
world::map—OTBM_TILEFLAG_PROTECTIONZONEprecomputed into aHashSetat load (mirrorsblocked/floor_change);is_protection_zoneandtemple_foradded. - ✅
world::game—PlayerStategainshealth,max_health,fist_skill,attacking: Option<u32>,last_attack_ms;CommandgainsSetTarget+CombatTick;Gamegains aStdRng(entropy-seeded; seedable in tests).do_set_targetenforces PZ rejection (push0xB4) and self-attack guard.on_combat_tickiterates fights, checks Chebyshev ≤ 1 same-floor range +MELEE_ATTACK_INTERVAL_MS(2000 ms), rollscombat::fist_damage, callsapply_damage.apply_damagepushes0x8Cto spectators +0xA0to the victim and firesdo_deathon 0 HP.do_deathpushes0x28, clears all fights targeting the victim, teleports the victim to the temple (remove+add pair — preserves M5 ≤1-creature-per-tile stackpos invariant), restores HP, and sends a fresh map +0xA0to the respawned player. The combat tick task is started inspawn. - ✅
server::game_service—reader_loopintercepts0xA1(parse_attack→world.set_target) and drains0xA2(follow, ignored) before the walk/turnopcode_actiondispatch.
Gate: cargo test 196 green (whole workspace), cargo clippy --all-targets -- -D warnings clean, #![forbid(unsafe_code)] intact.
Protocol gotchas (M7):
0xA1and0xA2are inbound AND outbound opcodes —0xA1inbound = attack;0xA2inbound = follow; outbound0xA1= skills,0xA2= icons. No conflict — namespaced by direction, exactly as0x6B/0x6Din M4.0xB4text message layout (sendTextMessage, protocolgame.cpp:1411):[0xB4][u8 type][u16-str text]. For PZ rejection:type = 21(MESSAGE_STATUS_SMALL, const.h:190), no extra fields before the string (the switch has no case for this type — it falls through toaddString).0x8Cgoes to all spectators including the victim AND attacker (they are both spectators of the victim's tile at melee range).0xA0goes only to the victim.last_attack_ms = 0priming: a newly set target fires on the first eligible tick whosenow_ms >= MELEE_ATTACK_INTERVAL_MS(not the very first tick, since the tick task consumes the immediatetick().await). This mirrors TFSplayer.cpp:3225-3226.- Death respawn is a remove+add pair (not a move): the death tile and the
temple are almost always out of each other's viewport, so
0x6Dwould deref a wrong stackpos on the spectators of the old tile. The remove at the death tile + add at the temple is the same atomic pair as logout/login. - No corpse in M7 — no second tile occupant, so the M5 ≤1-creature-per-tile invariant is untouched. Corpses land in M13 with the M9 ground-item stackpos.
Deferred: corpses/loot (M13); XP/skill/death-penalty (M14); mana/spells/
conditions (M15); monsters (M12); equipped weapons / real attackValue (M10);
fight modes / skulls / frags (M23); auto-walk follow; logout-in-fight block
(TODO marker already in reader_loop); unfair-fight reduction (M23).
Final holistic review caught four bugs fixed post-implementation (all TDD — red then green):
- W1 (respawn render):
do_deathonly put the victim inplaced; players standing near the temple were invisible to the respawned victim. Fixed:placednow includes all creaturesvisible_from(respawn_pos), introduced one-by-one (mirrorsdo_move'sothers_in_rangerebuild). - W2 (known-set prune):
do_deathnever pruned the victim'sknownset after the respawn teleport. Stale ids from the death tile would be sent short0x62form for creatures the client already discarded. Fixed: drop fromvictim.knownevery id not visible fromrespawn_pos(mirrorsdo_move's left-view prune). - W3 (PZ per-tick):
on_combat_tickchecked range but not protection zones; a victim who fled into the temple kept taking hits. Fixed: if either party is in PZ, clearattacker.attackingand skip — matches TFScanTargetCreature(combat.cpp:221-229) clearing the fight, not just suppressing damage. - S1 (drawblood effect id):
EFFECT_DRAWBLOOD = 2was wrong; TFSCONST_ME_DRAWBLOOD = 1, wire = TFS − 1 =0. Fixed: constant corrected and moved toenter_world.rsnext toEFFECT_TELEPORTfor consistency.
Live acceptance — ✅ ACCEPTED (2026-06-07): two OTClient Redemption sessions:
A right-clicks B → B's HP bar drains on both screens; B's own HP digits drop;
continued attacks kill B → B sees 0x28; standing on a PZ tile, A cannot attack
(status message). (The death→respawn flow was reworked in M7.1: death now logs
out and the relog spawns at the temple.)
Design + plan: docs/superpowers/specs/2026-06-07-m7.1-combat-polish-design.md,
docs/superpowers/plans/2026-06-07-m7.1-combat-polish.md. Scope: TFS-faithful
M7 polish found during live testing. Built in worktree m7.1-combat-polish
(rebased onto main after M8 landed).
- ✅
protocol::enter_world— blood-hit effect:EFFECT_DRAWBLOOD0→1. TFSsendMagicEffectsends the effect byte directly (protocolgame.cpp:2326) andCONST_ME_DRAWBLOOD = 1; wire0is dropped by the client as "no effect". - ✅
protocol::enter_world—icons(mask: u16)+ICON_PIGEON(1<<14, TFS const.h:343), replacing the staticicons(). - ✅
world::game(do_move) — push0xA2with/withoutICON_PIGEONwhen the mover crosses a protection-zone boundary (TFSgetClientIcons). - ✅
server::game_service— the enter-world burst carriesICON_PIGEONwhen the spawn tile is a PZ (map.is_protection_zone(center)). - ✅
world::game(do_death) — death is now a logout, not an in-world respawn. Send0x28, clear fights, id-form remove at the death tile, then remove the victim from the world and emit aSaveRecordat the temple with full HP. Dropping the victim'spush_txcloses the session → the client shows the death window and returns to character select; the relog spawns at the temple (M8loginrestores the saved position). Mirrors TFSonDeath→sendReLoginWindow+removeCreature(player.cpp:2070, 2197). Supersedes the M7 in-world respawn — the W1/W2 respawn-render + known-set prune +free_spawn_nearare removed (moot once death logs out).
Gate: cargo test green (225), cargo clippy --all-targets -- -D warnings clean,
#![forbid(unsafe_code)] intact.
Deferred (confirmed with roadmap owner): floor blood splat (ITEM_SMALLSPLASH
- decay) → M9; corpse body → M13; PK skull system → M8.1.
Live acceptance — ✅ ACCEPTED (2026-06-07): die in PvP → death window → character select → relog spawns at the temple with full HP; standing in the temple shows the PZ dove badge (clears on leaving); hits draw a visible blood animation.
Two combat-feedback gaps found in live testing.
1. ESC does not stop the attack (server keeps swinging). ✅ DONE — accepted
live. Alt-click a player, then press ESC: the red attack square disappears but
damage keeps landing. The square is cleared client-side — the client sends
0xBE (clientStop), which TFS maps to parseCancelMove →
Game::playerCancelAttackAndFollow (clears the attacked creature, clears follow,
stopWalk). Our reader_loop (server::game_service) had no 0xBE arm, so
the packet fell through to the opcode drain (None => continue) and attacking
was never reset — the global combat tick kept calling apply_damage. Fix:
combat_packets::OP_CANCEL_MOVE (0xBE) + a reader_loop arm calling
world.set_target(id, 0) (do_set_target already clears the fight on target 0).
Follow/auto-walk are no-ops today, so clearing the attack alone is faithful. Note:
0xBE is direction-overloaded — outbound it is walk::OP_FLOOR_CHANGE_UP; the
reader only matches inbound opcodes, so there is no conflict. Tests:
cancel_move_opcode_constant, cancel_opcode_is_dispatched_without_error.
2. Miss vs armor-blocked hits use the wrong effect. In classic Tibia the
on-hit animation depends on how the hit was stopped; TFS selects it by block
result in Game::combatBlockHit (game.cpp):
BLOCK_DEFENSE(dodged/parried, damage fully defended) →CONST_ME_POFF(3) — the puff.BLOCK_ARMOR(absorbed by armor) →CONST_ME_BLOCKHIT(10) — the sparks.- real damage lands →
CONST_ME_DRAWBLOOD(1) — blood. Ourapply_damage(world::game) always pushesEFFECT_DRAWBLOOD, even when the0..=maxroll comes up 0 (a natural miss → should show POFF). Plan: addEFFECT_POFF/EFFECT_BLOCKHITconstants toprotocol::enter_worldnext toEFFECT_DRAWBLOOD, and pick the effect by outcome instead of hard-coding blood. The miss-puff branch (roll 0 ⇒ POFF) is shippable now — no equipment needed. TheBLOCK_ARMOR⇒ BLOCKHIT branch needs real armor values from M10.2 (no armor model yet), so it lands with/after M10.2. Caution: client effect IDs are version-sensitive — verify the wire byte live, as we did for theEFFECT_TELEPORT10-vs-CONST_ME_TELEPORT-11 off-by-one (enter_world.rs:30).
Design + plan: docs/superpowers/specs/2026-06-07-m9-ground-items-look-design.md,
docs/superpowers/plans/2026-06-07-m9-ground-items-look.md. Scope: look-at
(examine) — the static item rendering roadmap M9 called for already shipped in
M4/M6.1 (StaticMap stores the full per-tile stack; the encoder serializes it with
the 10-thing cap). M9 added the examine path + the item metadata it needs. Built
spike-first (implement → live-validate → tests), subagent-driven on the
m9-ground-items-look branch.
- ✅
formats::items_xml—ItemXmlAttrsgainsname/article/plural/description/weight(hundredths of oz)/show_count; loader reads the<item>element attrs +description/weight/showcountchildren. Metadata lives inItemXmlAttrs(NOTItemType) to avoid churn on ~30 struct-literal sites. - ✅
formats::otb—is_pickupable()(FLAG_PICKUPABLE1<<5); weight shows only for pickupable items. - ✅
formats::otbm—MapItem.count: Option<u8>fromOTBM_ATTR_COUNT(15); a known-attr loop inparse_itemstops at the first unknown attr (no panic path).RUNE_CHARGESis a u8 (shares the COUNT case), corrected post-review. - ✅
protocol::look— pure wire:parse_look(0x8C[pos][spriteId u16][stackpos]),parse_look_battle(0x8D[id u32]),info_descr(0xB4MESSAGE_INFO_DESCR=22). - ✅
world::map—ItemMetacatalog onStaticMap(load_item_metadata, keyed by server id);TileStackcarries parallelserver_ids/counts; OTBM count threads intoWireItem.subtype.from_formats_with_spawnsignature unchanged (84 call sites) — onlymainpopulates the catalog at boot. - ✅
world::game—do_look/do_look_battleon the actor (owns tiles + creatures): STACKPOS_LOOK resolution under the ≤1-creature invariant,can_seegate, ChebyshevlookDistance(+15 cross-floor), TFS-faithful item text (getDescription/getNameDescription/getWeightDescription) and creature text (player.cppsubset: name, Level 1, "no vocation", He/She from M8 sex); weight + description only at distance ≤1; GM debug (Item ID+Position) gated on the login gamemaster flag (threaded viaInitialState/PlayerState). - ✅
server—reader_loopdispatches0x8C/0x8D;maincallsload_item_metadataaftermerge_items_xml.
Gate: cargo test 268 green (whole workspace), cargo clippy --all-targets -- -D warnings clean, #![forbid(unsafe_code)] intact.
Final holistic review (verdict SHIP, no CRITICAL) caught two edge-case bugs fixed
post-implementation: OTBM_ATTR_RUNE_CHARGES was read as u16 instead of u8 (would
desync an item's attr stream on the rare map item carrying rune charges); and a stray
world_light(250,…) daylight tweak swept into the look-dispatch commit was reverted.
One known limitation documented in creatures_on: when 2+ creatures co-occupy a
tile (stair landings), the id-order look resolution can swap which name is shown —
both render identically otherwise; deferred until it matters.
Live acceptance — ✅ ACCEPTED (2026-06-07): real OTClient examines a ground item near the temple (green "You see a …" with weight + description when adjacent, name only from afar); look at a second player → "You see (Level 1). He has no vocation." (and She for a female char); self-look → "yourself"; gamemaster login adds the item id + position lines.
Deferred: look in inventory/containers (M10), trade/shop (M16); action/unique/ decay/transform IDs + writable book text in GM debug; rune/weapon/armor stat lines; real level/vocation/mana/party/IP; dynamic ground items + runtime stack counts (M10).
Design + plan: docs/superpowers/specs/2026-06-07-m10.1-ground-items-move-design.md,
docs/superpowers/plans/2026-06-07-m10.1-ground-items-move.md. Scope: the mutable
item foundation — make the per-tile stack mutable at runtime and move items
ground→ground via 0x78, with stack merge/split, faithful throw-range + line of
sight, and live spectator broadcast. First slice of the M10 chain (M10.2 inventory,
M10.3 containers). Built spike-first (implement → live-validate → tests) via
subagent-driven TDD-free execution.
- ✅
formats::otb—is_moveable()(FLAG_MOVEABLE1<<6) +is_block_projectile()(1<<1);world::mapprecomputes ablock_projectiletile set at load (alongsideblocked). - ✅
world— copy-on-write overlay:Game.dynamic: HashMap<pos, TileStack>(sparse, materialise-on-first-touch) + aMergedTilesview implementing the existingTileSourcetrait (overlay-first, fall back toStaticMap), so the M3 map encoder is untouched.StaticMapstays immutable. - ✅
protocol—move_thing::parse_throw(inbound0x78);tile_item::add_tile_item(0x6A) /update_tile_item(0x6B) item forms (0x6Cremove reuses M5'sremove_tile_thing). - ✅
world::map— faithfulcanThrowObjectTo+isSightClear(Bresenham LOS, arg-swap + epsilon ported line-for-line from TFSmap.cpp). - ✅
world::game—do_move_thing: reach (Chebyshev ≤1 of source, same floor; no auto-walk) + throw/LOS validation + moveable gate; remove/split at source; merge same-type stackables (cap 100, overflow spills) or insert a new down item at the front of the down-items (newest on top, TFSgetBeginDownItem); broadcast0x6A/0x6B/0x6Cto spectators. Reads item flags from an extendedItemMeta. - ✅
server::game_service— dispatch0x78→world.move_thing.
Gate: cargo test green (workspace), cargo clippy --all-targets -- -D warnings
clean, #![forbid(unsafe_code)] intact.
Review caught (fixed pre/post live): item duplication when the requested
count exceeded the available stack (clamp moved = count.min(available)); a silent
merge-spill (an overflow >100 created a new stack that was never broadcast — now
emits a second 0x6A); a missing from == to no-op guard; spill u8 truncation.
Live acceptance — ✅ ACCEPTED (2026-06-07): real OTClient — drag a movable ground item to another tile; merge gold onto gold; split a stack; a non-movable item refuses to move; line-of-sight blocks a throw through a wall; a second client sees every change live. Stacking-order bug found in live test and fixed: a moved item now inserts at the front of the down-items so it renders on top of the pre-existing item (was appending → rendered underneath), matching TFS.
Known limitation (deferred): the enter-world login burst encodes from the static
map, not the overlay — a player logging in after an item moved mid-session won't see
that change on their initial screen until they walk (walk slices use MergedTiles).
Dynamic ground items reset on restart (TFS doesn't persist them either). LOS reads the
static block_projectile set (movable items aren't sight-blocking, so it's authoritative).
Deferred to the rest of the M10 chain / later: inventory/container 0x78 endpoints,
pickup/drop, weight/cap (M10.2); containers + use-to-open (M10.3); use content
(levers/keys/runes) → M11/Lua; item decay; persistence of dynamic ground items.
Design + plan: docs/superpowers/specs/2026-06-06-m6.1-stairs-design.md,
docs/superpowers/plans/2026-06-06-m6.1-stairs.md. Scope: walk-driven vertical
movement — stairs up/down, underground (z≥8), multi-floor presence. Built in an
isolated worktree (m6.1-stairs branch) via subagent-driven TDD. Two TFS-verified
mechanics: height slopes (game.cpp:792-820, hasHeight(3) from the .otb)
and floorChange staircase tiles (tile.cpp::queryDestination, direction from
items.xml).
- ✅
formats::items_xml—FloorChangebitmask +ItemType.has_height/floor_change;FLAG_HAS_HEIGHT(bit 3) from the.otb. - ✅
formats::items_xml— completeitems.xmlloader (roxmltree),floorChangestring→flags,fromid/toidranges,merge_items_xmlintoItemType. Real-file parse tested. - ✅
world::map— per-tilefloor_change/tile_heightprecomputed at load;resolve_floor_change(faithfulqueryDestinationport — verified line-by-line) +triggers_up. - ✅
protocol::map_description—get_map_descriptionrefactored into per-floorfloor_description(sharedskip) + the undergroundz-2..=z+2band (floor_range). Overground stays byte-identical. - ✅
protocol::walk—0xBEmove-up /0xBFmove-down (faithfulMoveUpCreature/MoveDownCreatureports, byte-verified) + z-awarewalk_update(id-form remove at the 7→8 boundary). - ✅
world::game— verticaldo_move: height mechanic A then floorChange mechanic B; stair/height landings reached with TFSFLAG_NOLIMITsemantics (block-solid on the landing is ignored; the ≤1-creature-per-tile invariant kept). - ✅
world::game— multi-floor presence:can_see±2 band with the per-flooroffsetzx/y projection (matches the encoder), plus a remove+add at the 7→8 boundary for other-creature broadcasts (TFSsendMoveCreature2633-2649). - ✅
server::main— load +merge_items_xmlat boot; the live world has real floor-change data.
Gate: cargo test 129 green, cargo clippy --all-targets -- -D warnings clean, #![forbid(unsafe_code)] intact.
Final holistic review caught two integration gaps the per-task reviews missed (both fixed): can_see lacked the floor offsetz (cross-floor spectator desync), and the spectator broadcast lacked the 7→8 remove+add. Solo mechanics + the mover's own camera were verified TFS-faithful throughout.
Live acceptance — ✅ ACCEPTED (2026-06-07): real OTClient descends the Thais
temple staircase into the underground and climbs back, rendering correctly; a
second client one floor away sees the crossing — no desync. Known
untested-in-prod: full underground map description (encode with z>7) has no
live path yet (login always spawns at z=7); it's unit-tested but not exercised
until relog/teleport-underground exists.
Design + plan: docs/superpowers/specs/2026-06-06-m6-chat-design.md, docs/superpowers/plans/2026-06-06-m6-chat.md. Scope: TFS-faithful local chat — say/whisper/yell, position-based. The roadmap's "default channel" = local positional speech (OTClient Local Chat tab); NO joinable-channel system. Cheap because it reuses the M5 spectator + push machinery.
- ✅
protocol::chat—parse_say(body) -> Option<(SpeakType, String)>(inbound0x96=[type u8][msg str]; rejects unsupported types / empty / malformed) andcreature_say(outbound0xAA=[stmt u32][name str][level u16][type u8][x u16][y u16][z u8][msg str]).SpeakType { Say=1, Whisper=2, Yell=3 }. - ✅
world::game—Command::Say+do_say. The actor (single packet builder) reads the speaker's current pos+name, allocates a statement id, and broadcasts0xAA.spectatorsgeneralized tospectators_in_range(pos, exclude, rx, ry). Ranges: say ±8/±6, yell ±18/±14 + UPPERCASE, whisper ±8/±6 with full text to Chebyshev ≤1 and"pspsps"to in-view-but-far. Speaker always hears own (pushed explicitly, sincespectatorsexcludes self). - ✅
server::game_service—reader_loopintercepts0x96before the walk/turn dispatch,chat::parse_say(&payload[1..])→world.say(...); unsupported/malformed dropped. - ✅ Accepted live — two OTClients: say heard nearby (not when far); whisper full only to the adjacent client (far-in-view sees
pspsps); yell heard far in UPPERCASE. Off-screen yell appears in the chat console but shows no floating bubble — correct:addStaticTextis positional, so a bubble only renders when the speaker is on the recipient's screen (matches TFS/OTClient).
Key seam (confirmed clean in final holistic review): chat depends on no M5 presence state. 0xAA carries the speaker NAME (string) + POSITION, not a creature id — so a yell reaching a player who never had the speaker introduced (off their viewport, not in their known-set) still renders. do_say only READS positions; it never touches stackpos, the known-set, or the ≤1-creature-per-tile invariant.
Deferred (YAGNI): joinable channels (0x97/0x98/0xAB/0xAC); private messages (TALKTYPE_PRIVATE_*); yell cooldown + anti-spam; multi-floor yell; real speaker level (sent as 1 until M14); NPC/monster speech (M12). Over-255 messages are truncated (TFS drops them) — a deliberate, documented divergence.
- Inbound
0x96body for say/whisper/yell is just[type u8][msg str](parseSay, protocolgame.cpp:922). Private (5/16) carries a receiver-name string and channel (7/14) achannelId u16BEFORE the message — M6 rejects those inparse_say. - Outbound
0xAAis name+position based, not creature-id based (sendCreatureSay, protocolgame.cpp:2199). It does not depend on the client knowing the creature, so far yells and off-screen speech render fine. sendToChannelis the same0xAAbut with achannelId u16instead of the position — that's the joinable-channel path, deferred.- Ranges (game.cpp): say/whisper query = client viewport ±8/±6; whisper full text only within Chebyshev 1 (3×3), else literal
"pspsps"(same WHISPER type); yell = ±18/±14 (TFS multifloor; we are same-floor) and text uppercased. - One statement id per utterance, shared by all recipients (TFS
lastStatementId); ours is au32wrapping_addcounter starting at 1.
0x6Aadd-tile-creature wraps the creature thing:[0x6A][pos x:u16 y:u16 z:u8][stackpos:u8]then theAddCreaturebytes (0x61/0x62). The raw creature thing alone is not enough — the client needs the tile + stackpos. (protocolgame.cpp:2517)0x6Cshort form is[0x6C][pos][stackpos:u8]for stackpos < 10 (protocolgame.cpp:3101); the id-form (0xFFFF+id) is only for stackpos ≥ 10 and is deferred.- Walk broadcast transition matrix (spectator union of
from+to): sees both →0x6Dmove; onlyto→0x6Aappear; onlyfrom→0x6Cremove (and drop the id from that spectator's known-set so re-entry re-introduces with0x61). - The mover's own view still uses
walk_update(0x6D + revealed slices); other in-range players are spliced into the new slices viaPlacedCreature. The client auto-culls creatures that scroll off the edge, so the mover gets no explicit removes. - Coalescing multiple game packets into one XTEA frame is legal — the client decrypts the whole body and parses opcodes sequentially (TFS coalesces into one
OutputMessagetoo). The writer drains the channel greedily, so batching happens under load with zero added latency when idle (vs TFS's fixed 10 ms autosend tick).
- Every item is
[u16 clientId][u8 0xFF MARK_UNMARKED]at 1098 (networkmessage.cpp:86). The0xFFis part of the item, not a tile terminator. - Tile thing order (
GetTileDescription:583): envu16 0x0000, ground item, top items, creatures (reverse) viaAddCreature, down items. The tile ends at the next[skip][0xFF]flush — there is no per-tile terminator. Creature markers0x0061/0x0062are< 0xFF00, so the client reads them as things, not skip markers. - Diagonal steps send two slices, not a full
0x64.sendMoveCreature's y and x checks are independentifblocks; a diagonal emits the applicable Y-slice and X-slice both. - Slice anchors (
sendMoveCreature:2616-2630, viewport 8×6): north/south anchor onoldPos.x-8andnewPos.y∓; east/west anchor onnewPos.x±andnewPos.y-6. The slice still runs all 8 floors (GetMapDescription). - A lone creature on a ground-only tile has stackpos = 1 (ground = 0).
0x6D/0x6Buse the stackpos < 10 form. - Incoming vs outgoing opcode overlap is fine:
0x6Dinbound = walk-NW, outbound = creature-move;0x6Binbound = walk-SE, outbound = creature-turn. Tibia opcodes are namespaced by direction.
ProtocolGameid byte is0x0A(vs0x01for login). Our frame payload keeps the protocol-id byte;game_login::parsereads it first. The game port skips only 7 bytes after version (u32 clientVersion + u8 type + u16 datRevision), unlike the login server's 17.- Skip-encoding must be a byte-faithful TFS port (
protocolgame.cpp:633-680):skipstarts-1and persists across all 8 floors; on empty tile checkskip == 0xFEbefore incrementing ([0xFF][0xFF]flush + reset-1); on a real tile flush[skip][0xFF]ifskip>=0, thenskip=0, write[env 0x0000][item]; final[skip][0xFF]flush. OTClient'ssetFloorDescriptionis the exact mirror — do NOT invent a self-consistent scheme. - Byte layouts are version-gated. At 1098:
self_info 0x17= 29 B;stats 0xA0= 53 B with health/mana as u16 (u32 only fromGameDoubleHealth≥ 1300). Verify field sets against the OTClient parse, not just TFS send code. - After parsing the game-login packet, enable XTEA for all subsequent traffic (the
0x32ext-opcode and the whole burst are XTEA-encrypted + checksummed; the0x1Fchallenge is checksummed only). - OTClient OS values ≥ 10 (
CLIENTOS_OTCLIENT_LINUX) trigger a0x32extended-opcode init packet right after the key exchange.
- Both formats share the same node-tree container (
fileloader.cpp):[u8;4 identifier][0xFE root-type][root props]( child | 0xFF )*.0xFEopens a child (next byte = type),0xFFcloses,0xFDescapes the next byte so markers can appear in props. A node's props are the bytes between its type byte and its first child / its END, with escapes removed. - The root node's TYPE byte is
0x00, NOTOTBM_ROOTV1(1). TFS never validatesroot.type; it reads theOTBM_root_headerstraight from root props and only checks the single child isOTBM_MAP_DATA(2). Don't assert on root type. - All integers little-endian; structs are
#pragma pack(1).OTBM_root_header=u32 version, u16 width, u16 height, u32 majorItems, u32 minorItems(16 bytes).items.otbroot =u32 flags, u8 ROOT_ATTR_VERSION(0x01), u16 len=140, VERSIONINFO{u32 major, u32 minor, u32 build, u8[128]}. items.otbitem node: type =itemgroup_t; props =u32 flagsthen[u8 attr][u16 len][data].ITEM_ATTR_SERVERID=0x10,CLIENTID=0x11(both u16). Unknown attrs: skiplenbytes..otbminline ground item (OTBM_ATTR_ITEM=9inside tile props) is exactly au16id —Item::CreateItem(PropStream)reads only the id (no count in OTBM v2). Stacked items are separate childOTBM_ITEM(6) nodes; container contents nest as further child item nodes. Tile attrs:TILE_FLAGS=3(u32),ITEM=9. HOUSETILE(14) prepends au32 houseIdbefore attrs.- Map-data attrs:
DESCRIPTION=1,EXT_SPAWN_FILE=11,EXT_HOUSE_FILE=13(all strings). Tile coords arebase(u16 x,u16 y,u8 z)per TILE_AREA +(u8 dx,u8 dy)per tile. - Reference files (
reference/tfs/data/...) are gitignored — format tests skip gracefully (eprintln "skipping") when absent, so CI without the TFS tree stays green.
- Reminder: on garbage/disconnect, suspect checksum / XTEA padding / inner-vs-outer length mismatch FIRST.
- Frame:
[u16 LE length][u32 Adler-32 of rest][payload]. After login, all traffic XTEA-encrypted (32 rounds, 8-byte blocks, padding counts toward inner length). - spr/dat are the extended variant (u32 sprite IDs) — irrelevant to the server; it only reads
items.otb+.otbm. - First login packet is NOT XTEA-encrypted (the key is inside its RSA block) but IS checksummed. Every packet after the handshake — including the login server's own response — is XTEA-encrypted. The login response carries the char list encrypted with the key the client just sent.
- Send path: prepend
[u16 inner_len], zero-pad the whole[len][payload]to a multiple of 8, XTEA-encrypt, prepend Adler-32, prepend[u16 outer_len]. Recv path: outer_len → verify checksum over the rest → XTEA-decrypt → read inner_len → take that many payload bytes. (xtea::encrypt_message/decrypt_message.) - RSA block: TFS
RSA_decryptconsumes the leading zero byte itself; XTEA key reads start at decrypted offset 1. Layout:[u8 0][u32x4 key][string account][string password]. - Login header skip is version-dependent:
version >= 971has au32 protocolVersionfield (skip 17 = 4+12 sigs+1 zero between version and RSA); older skips 12. We require ≥ 971. - Error opcode:
0x0Bfor client ≥ 1076, else0x0A. - TFS
decryptloopfor i=63; i>0; i-=2underflows in Rustusizeat i=1 — use(1..64).rev().step_by(2). - The bundled
otclient-src/is obfuscated (renamed symbols); TFS 1.4.2 is the layout oracle. A real-OTClient packet capture would harden the replay test.