From 6daf188b9a6334798a9a7badd2ca3357a280af2d Mon Sep 17 00:00:00 2001 From: jaenster Date: Mon, 7 Sep 2026 16:39:46 +0200 Subject: [PATCH 1/3] 1.13c: drive the engine cooperatively, and say which drain loop ran MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uncommitted work that was already in the tree, committed separately so the save-rollback changes that follow are readable on their own. process_all_games / dispatch_cleanup are the drive model a real 1.13c server uses: process_all_games is the only caller of ServerGameLoop, which populates newly-activated rooms and reveals them as a client moves. The per-game worker model still gets a character into a world — the join sends the initial view — and then nothing else ever happens. D2Net reports each of the three drain loops once. They differ only by the list index they push, so an ordinal on the wrong one is invisible: packets routed to the loop the engine never asks for accumulate and are never seen, which reads as the engine ignoring the player while the connection stays healthy. --- apps/d2gs/engine/server.zig | 11 +++-- apps/d2gs/runtime/gameloop.zig | 27 ++++++++++++- packages/d2engine/gameflags.zig | 40 ++++++++++++++----- packages/d2engine/version.zig | 22 ++++++++++ packages/d2net/d2net.zig | 18 +++++++++ .../gs-seats/gs_seats.zig | 0 6 files changed, 102 insertions(+), 16 deletions(-) rename apps/d2gs/realmclient/joinctx.zig => packages/gs-seats/gs_seats.zig (100%) diff --git a/apps/d2gs/engine/server.zig b/apps/d2gs/engine/server.zig index 51aad368..46e727b7 100644 --- a/apps/d2gs/engine/server.zig +++ b/apps/d2gs/engine/server.zig @@ -127,12 +127,11 @@ pub fn GAME_CreateBattleNetGame( pub const ARENAFLAG_ClientUpdate: u32 = 0x04; pub const ARENAFLAG_Hardcore: u32 = 0x800; // 2048 pub const ARENAFLAG_Expansion: u32 = 0x10_0000; // 1048576 -// Bit 21 -> pGame->eGameType (eGameType = (flags>>0x15)&1). Setting it marks the game NOT -// single-player (0x01 GameFlags packet renders online NPCs, e.g. Cain by the Act 5 waypoint), -// BUT also routes CLIENT_LoadCharacterAndSendGameData into a branch that refuses joins with -// nReason 0x19 (verified live: on -> all joins refused, off -> joins succeed). Leave CLEAR -// until that char-load path is understood — a working join beats Cain's cosmetic position. -pub const ARENAFLAG_Multiplayer: u32 = 0x20_0000; // 2097152 (bit 21) — intentionally NOT set +/// Bit 21 -> pGame->eGameType, which is the LADDER flag: the client gets it as byte 7 of the 0x01 +/// GameFlags packet and hands it to CLIENT_SetLadder, and CalculateGetFlags @0x569d80 refuses a +/// join whose character disagrees with it (0x19 ladder char in a non-ladder game, 0x1a the +/// reverse). It must track the game's ladder byte — see packages/d2engine/gameflags.zig. +pub const ARENAFLAG_Ladder: u32 = 0x20_0000; // 2097152 (bit 21) /// Shared with the pre-1.14 host: both servers create games and the engine is equally unforgiving /// about ARENAFLAG_ClientUpdate on either. pub const gameFlags = @import("d2engine").gameflags.gameFlags; diff --git a/apps/d2gs/runtime/gameloop.zig b/apps/d2gs/runtime/gameloop.zig index 9d31abe5..2d7eacae 100644 --- a/apps/d2gs/runtime/gameloop.zig +++ b/apps/d2gs/runtime/gameloop.zig @@ -4,6 +4,7 @@ //! through menus into a game (via the async fiber tasks). const patch = @import("patch.zig"); +const log = @import("../log.zig"); const async_ = @import("async.zig"); const feature = @import("../engine/feature.zig"); @@ -32,6 +33,26 @@ fn hookGameLoop() callconv(.c) void { Sleep(GAME_FRAME_SLEEP_MS); // yield: we clobbered the engine's own loop sleep } +/// The in-game site replaces `cmp dword [0x70f7e0], 0` — and the instruction immediately after it, +/// `jne 0x451c4a` @0x451c31, branches on THAT comparison's flags. Calling a C function in its place +/// leaves the flags as whatever our last operation happened to set (in practice, whatever returning +/// from Sleep leaves), so the engine's own frame sleep was being taken or skipped at random and the +/// in-game frame was 10ms or 20ms from one iteration to the next. +/// +/// We sleep for the frame ourselves, so the engine's sleep is the one we want SKIPPED, every time — +/// that is the `jne` being taken, i.e. ZF clear. `test esp, esp` clears ZF unconditionally (ESP is +/// never zero) and touches nothing else. It must be the last flag-affecting instruction before the +/// `ret`, which is why this shim is naked asm rather than something appended to the Zig function. +fn gameLoopShim() callconv(.naked) void { + asm volatile ( + \\call %[cb:P] + \\test %%esp, %%esp + \\ret + : + : [cb] "X" (&hookGameLoop), + ); +} + fn hookOogLoop() callconv(.c) void { async_.init(); if (on_oog) |cb| cb(); @@ -40,7 +61,11 @@ fn hookOogLoop() callconv(.c) void { } pub fn install() void { - _ = patch.MemoryPatch(ADDR_GAME_LOOP).call(@intFromPtr(&hookGameLoop)).nops(2).commit(); + // cmp dword ptr [0x70f7e0], 0 — stated so a drifted address refuses instead of corrupting. + const game_loop_orig = [_]u8{ 0x83, 0x3D, 0xE0, 0xF7, 0x70, 0x00, 0x00 }; + if (!patch.MemoryPatch(ADDR_GAME_LOOP).expect(&game_loop_orig).call(@intFromPtr(&gameLoopShim)).nops(2).commit()) { + log.print("gameloop: in-game frame hook NOT installed"); + } _ = patch.MemoryPatch(ADDR_OOG_LOOP).call(@intFromPtr(&hookOogLoop)).nops(18).commit(); } diff --git a/packages/d2engine/gameflags.zig b/packages/d2engine/gameflags.zig index 5a4a9096..cb8d0480 100644 --- a/packages/d2engine/gameflags.zig +++ b/packages/d2engine/gameflags.zig @@ -1,5 +1,5 @@ //! The flags a game is created with (`eD2ArenaFlags`), shared because both servers create games and -//! the engine is unforgiving about one of them. +//! the engine is unforgiving about two of them. //! //! `ARENAFLAG_ClientUpdate` is not optional. `ARENA_NeedsClientUpdate` @0x6fc31690 reads it back as //! `(*(u8*)(pGame[0x1d28] + 8) >> 2) & 1`, and `D2GAME_UpdateAllClients` @0x6fc389c0 — the function @@ -7,6 +7,17 @@ //! clear: `This should never happen! [sUpdateClients]`. A game created with flags 0 therefore takes //! the whole server down the first time its task runs, and nothing in the message points at the //! flags. +//! +//! Bit 21 is the LADDER bit, and it must track the character's ladder status or joins are refused. +//! `GAME_CreateBattleNetGame` @0x530930 stores it as `pGame->eGameType = flags >> 0x15 & 1`, and +//! two things read it back: +//! - `NET_D2GS_SERVER_Send_0x01_GameFlags` @0x53b340 sends `eGameType != 0` as byte 7, which the +//! client feeds to `CLIENT_SetLadder` @0x44dc90 — that is what gates ladder-only content. +//! - `CalculateGetFlags` @0x569d80 refuses the join outright on a mismatch: a ladder character +//! (.d2s status 0x40) needs `eGameType != 0` or it returns nReason 0x19, and a non-ladder +//! character needs `eGameType == 0` or it returns 0x1a. +//! The reference servers agree: pvpgn's 1.13c GS builds the same word and sets 0x200000 from the +//! request's `ladder` byte (handle_s2s.c), and 1.09d — which had no ladder — never sets it at all. const std = @import("std"); @@ -18,25 +29,36 @@ pub const client_update: u32 = 0x04; pub const hardcore: u32 = 0x800; pub const expansion: u32 = 0x10_0000; -/// Bit 21. Deliberately NOT set: it moves Deckard Cain, but also routes the character load into a -/// branch that refuses joins with `nReason 0x19` — verified live on 1.14d, where setting it refused -/// every join and clearing it let them through. -pub const multiplayer: u32 = 0x20_0000; +/// Bit 21 — `pGame->eGameType`, which is the ladder flag. See the file comment. +pub const ladder: u32 = 0x20_0000; -pub fn gameFlags(diff: u3, is_expansion: bool, is_hardcore: bool) u32 { +pub fn gameFlags(diff: u3, is_expansion: bool, is_hardcore: bool, is_ladder: bool) u32 { var f: u32 = @as(u32, diff) << difficulty_shift; f |= client_update; if (is_expansion) f |= expansion; if (is_hardcore) f |= hardcore; + if (is_ladder) f |= ladder; return f; } test "client update is the bit the engine reads back" { // ARENA_NeedsClientUpdate does (flags >> 2) & 1, so this bit and that shift must agree. - try std.testing.expectEqual(@as(u32, 1), (gameFlags(0, false, false) >> 2) & 1); + try std.testing.expectEqual(@as(u32, 1), (gameFlags(0, false, false, false) >> 2) & 1); } test "difficulty lands in bits 12-14" { - try std.testing.expectEqual(@as(u32, 2 << 12), gameFlags(2, false, false) & (0x7 << 12)); - try std.testing.expectEqual(@as(u32, 0x10_0000 | 0x800), gameFlags(0, true, true) & ~client_update); + try std.testing.expectEqual(@as(u32, 2 << 12), gameFlags(2, false, false, false) & (0x7 << 12)); + try std.testing.expectEqual(@as(u32, 0x10_0000 | 0x800), gameFlags(0, true, true, false) & ~client_update); +} + +test "ladder lands in bit 21, which the engine reads as eGameType" { + // GAME_CreateBattleNetGame: pGame->eGameType = flags >> 0x15 & 1. + try std.testing.expectEqual(@as(u32, 1), (gameFlags(0, true, false, true) >> 0x15) & 1); + try std.testing.expectEqual(@as(u32, 0), (gameFlags(0, true, false, false) >> 0x15) & 1); +} + +test "matches the word pvpgn's 1.13c GS builds" { + // handle_s2s.c: 0x04 | expansion 0x100000 | ladder 0x200000 | hardcore 0x800 | difficulty << 12. + try std.testing.expectEqual(@as(u32, 0x0030_2804), gameFlags(2, true, true, true)); + try std.testing.expectEqual(@as(u32, 0x0010_0004), gameFlags(0, true, false, false)); } diff --git a/packages/d2engine/version.zig b/packages/d2engine/version.zig index ac3b3a4a..8909e3d5 100644 --- a/packages/d2engine/version.zig +++ b/packages/d2engine/version.zig @@ -69,6 +69,21 @@ pub const GameOrdinals = struct { create_empty_game: u16 = 10047, set_init_seed: u16 = 10010, shutdown: u16 = 10050, + /// The COOPERATIVE drive model, and the one a real 1.13c server uses. + /// + /// `process_all_games` walks the game array itself and is the only caller of the engine's + /// `ServerGameLoop` — the function that populates newly-activated rooms with their preset + /// units (`InitNewRooms`: NPCs, shrines, chests) and reveals rooms to a client as it moves + /// (`UpdateClients` -> `GAME_OnClientRoomChange` -> `CLIENT_RevealRoomAndSendUnits`). Driving + /// the engine with the per-game worker model instead still gets a character into a world, + /// because the initial view is sent by the JOIN, and then nothing else ever happens: no NPC + /// can be interacted with, no shrine appears, and the level materialises only in the bands the + /// join happened to cover. + /// + /// `dispatch_cleanup` is the outbound half, called only when a game actually ticked. + /// Both null on a version that has not been measured; such a version keeps the per-game model. + process_all_games: ?u16 = null, + dispatch_cleanup: ?u16 = null, }; /// D2Common's host-facing entry points. Unlike D2Game's, these did NOT stay put: classic's export @@ -232,6 +247,13 @@ pub fn spec(comptime v: Version) Spec { .create_empty_game = 10044, .set_init_seed = 10017, // unverified .shutdown = 10047, + // Marsgod's working 1.13c D2Server drives the engine with exactly these two and + // never touches 10056: its whole per-frame loop is + // `10040(); if (10008(0)) 10024(0,0);`, which is 1.14d's + // QSERVER_CooperativeThreadMain @0x44cf20 one-for-one + // (HandleAnyIncomingPacket -> SrvProcessAllGames @0x52fc20 -> DispatchAndCleanup). + .process_all_games = 10008, + .dispatch_cleanup = 10024, }, .common = .{ .load_all_txts = 10943, .set_compile_tables = 10563 }, // 1.13c permuted D2Lang's whole NONAME block: its 10000 is a string hash taking diff --git a/packages/d2net/d2net.zig b/packages/d2net/d2net.zig index 36a252e7..340d7119 100644 --- a/packages/d2net/d2net.zig +++ b/packages/d2net/d2net.zig @@ -438,16 +438,34 @@ export fn SERVER_Send(kind: u32, client: u32, data: ?[*]const u8, len: u32) call /// -1, not 0: the drain loops break on -1 and 0 is a legitimate length. const no_message: u32 = 0xFFFF_FFFF; +/// Which of the three drain loops the engine has actually asked for, reported once each. +/// +/// The three are separated only by the list index they push, so an ordinal assigned to the wrong +/// one is invisible: the engine keeps draining the lists it knows about and the packets routed to +/// the one it never asks for simply accumulate and are never seen again. That failure reads as the +/// engine ignoring the player — no movement, no interaction — while the connection stays healthy, +/// so it is worth one line each to know the loop is running at all. +var list_drained: [3]bool = .{ false, false, false }; + +fn noteDrain(list: u32) void { + if (list >= list_drained.len or list_drained[list]) return; + list_drained[list] = true; + sayFmt("d2net: engine drained message list {d} for the first time", .{list}); +} + export fn SERVER_ReadFromMessageList0(buf: ?[*]u8, len: u32) callconv(.winapi) u32 { + noteDrain(0); return takeMessageFor(0, buf, len); } export fn SERVER_ReadFromMessageList1(buf: ?[*]u8, len: u32) callconv(.winapi) u32 { + noteDrain(1); return takeMessageFor(1, buf, len); } /// List 2's processor reads its opcode at `buf[5]`, not `buf[4]` like the other two, so it takes a /// different envelope entirely. Nothing we originate belongs there yet. export fn SERVER_ReadFromMessageList2(buf: u32, len: u32) callconv(.winapi) u32 { + noteDrain(2); _ = buf; _ = len; return no_message; diff --git a/apps/d2gs/realmclient/joinctx.zig b/packages/gs-seats/gs_seats.zig similarity index 100% rename from apps/d2gs/realmclient/joinctx.zig rename to packages/gs-seats/gs_seats.zig From 010fb9085d24b770fc338ee946646e385ed7853b Mon Sep 17 00:00:00 2001 From: jaenster Date: Mon, 7 Sep 2026 16:40:34 +0200 Subject: [PATCH 2/3] Make character rollbacks impossible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No deployed game server was writing character saves at all. d2gs-native cleared the engine's own -nosave flag and never replaced it; d2host answered fpSaveDatabaseCharacter with a stack-balancing stub. Only the wine-injected 1.14d DLL implemented the slot, and that one filed saves under the wrong key whenever its 16-slot join ring had recycled the player's entry. Every character on the realm read charver=1: created, and never saved since. The store now refuses to go backwards, rather than every path above it being careful. realmd:charver is a compare-and-set token: a load returns the bytes and the version they were at, and a save is accepted only while the store is still at that version. The whole compare-set-increment is one Lua script. A version of zero is accepted whatever the caller expected, because that means redis lost its state and was refilled from postgres, where a live session's bytes are the newest thing in existence. A refused save is never retried; it is obsolete. Character names are now claimed realm-wide instead of per account. Names were unique only within an account, while the seat tables, the retry queue, a departing player's seat release and the Mac engine's own .d2s all key on the name alone — two accounts with a "Bob" is not a rollback but a character swap. The claim is a table rather than an index on chars, so it cannot fail at startup on data that already contains duplicates. Also closed: deleting a character that is in a game (the server holds it and writes it back, resurrecting it or landing old bytes on a recreated one); releasing a seat by matching a /name suffix over an unordered SMEMBERS, which freed a lock belonging to somebody still playing; a leave now carries the account so the seat is named rather than guessed; and the save-retry queue keyed by name alone, carried no fence, and had no age bound. Saving is continuous everywhere. 1.14d's interval drops from 8192 game frames (~5.5 min) to 512 (~20s) by patching two immediates in UpdateClients. The pre-1.14 engines find the same site by its constant instead of by address — 0x80001fff is the signed-modulo-8192 idiom and nothing else holds that number — and require exactly one match, refusing rather than guessing. All ten shipped D2Game.dll files have exactly one, in both encodings, with the fixup at +7. joinctx moves to packages/gs-seats and is shared by all three servers, which previously had one table, another table, and none. Verified on the real engines under wine: the interval patch applies on all six, a client reaches a world on 1.14d and its charver climbs 1 -> 2 -> 3, and a save replayed at a superseded version is refused with the character left intact. The e2e suite failed about one run in two because pg_isready answers from the temporary server postgres runs during first-boot initdb, which listens on a unix socket while docker has already published the port. It now waits for a query over TCP. 27 consecutive clean runs. --- apps/d2gs-native/chardb.zig | 72 ++- apps/d2gs-native/realm.zig | 93 +++- apps/d2gs-native/savededupe.zig | 206 +++++++++ apps/d2gs-native/store.zig | 72 +++ apps/d2gs/d2gs.zig | 16 + apps/d2gs/engine/realm.zig | 199 ++++++-- apps/d2gs/realmclient/d2cs.zig | 49 +- apps/d2gs/runtime/autosave.zig | 93 ++++ apps/d2gs/runtime/feature/srvtrace.zig | 91 ++++ apps/d2gs/runtime/patch.zig | 39 +- apps/d2gs/runtime/rejoin.zig | 2 +- apps/d2host/main.zig | 339 ++++++++++---- apps/realmd/admin.zig | 6 + apps/realmd/d2cs.zig | 38 +- apps/realmd/fleet.zig | 20 +- apps/realmd/store.zig | 32 ++ build.zig | 24 + packages/d2engine/charsave.zig | 153 +++++++ packages/d2engine/d2engine.zig | 4 + packages/d2engine/saveinterval.zig | 264 +++++++++++ packages/gs-seats/gs_seats.zig | 599 ++++++++++++++++++++++--- packages/gs-store/gs_store.zig | 180 ++++++++ packages/gs-store/savequeue.zig | 436 ++++++++++++++++++ packages/realm-proto/protocol.zig | 7 +- packages/realm-store/pg.zig | 62 +++ packages/realm-store/redis.zig | 55 ++- tools/e2e/fakegs.zig | 38 ++ tools/e2e/main.zig | 347 +++++++++++++- tools/e2e/realmclient.zig | 56 +++ 29 files changed, 3378 insertions(+), 214 deletions(-) create mode 100644 apps/d2gs-native/savededupe.zig create mode 100644 apps/d2gs/runtime/autosave.zig create mode 100644 packages/d2engine/charsave.zig create mode 100644 packages/d2engine/saveinterval.zig create mode 100644 packages/gs-store/savequeue.zig diff --git a/apps/d2gs-native/chardb.zig b/apps/d2gs-native/chardb.zig index 06b2de4e..ee55fcc8 100644 --- a/apps/d2gs-native/chardb.zig +++ b/apps/d2gs-native/chardb.zig @@ -28,6 +28,7 @@ extern "c" fn close(fd: c_int) c_int; extern "c" fn recv(fd: c_int, buf: [*]u8, len: usize, flags: c_int) isize; extern "c" fn send(fd: c_int, buf: [*]const u8, len: usize, flags: c_int) isize; extern "c" fn fwrite(ptr: [*]const u8, size: usize, n: usize, f: *anyopaque) usize; +extern "c" fn fread(ptr: [*]u8, size: usize, n: usize, f: *anyopaque) usize; extern "c" fn fclose(f: *anyopaque) c_int; extern "c" fn unlink(path: [*:0]const u8) c_int; extern "c" fn usleep(usec: c_uint) c_int; @@ -61,7 +62,7 @@ const save_source_memory: u8 = 2; /// What the engine will read on the file path: `fread(buf, 1, 0x2000, f)` at 0x0020ab5b. A save /// larger than this is one it would truncate, so it is not one worth carrying either way. -const max_save = 0x2000; +pub const max_save = 0x2000; /// One save in flight per seat in the game, and the engine admits eight clients (0x001a7a66). Any /// more would be a character nobody asked for. @@ -111,12 +112,38 @@ pub fn installLoadHook(loaded: *const macho.load.Loaded) void { at[0] = 0xe8; std.mem.writeInt(i32, at[1..5], rel, .little); - // Stop the other half of the round trip: `SaveToFile` (timer, `ServerGameLoop`) writes - // `.d2s`, but nothing reads it back once loads come from the realm. - // Clearing this flag is the engine's own `-nosave`; only `ClientInit_SetNoSaveFlag` writes - // this word and only ever with 0, so a value set here before init sticks. + // The other half of the round trip stays ON. `SaveToFile` (timer, `ServerGameLoop`) writes + // `.d2s`, and this used to be cleared — the engine's own `-nosave` — + // because nothing read those files back once loads came from the realm. + // + // Nothing read them back, and nothing wrote the character anywhere else either: this server + // played perfectly and persisted NOTHING. Every session ended where it began. The file is now + // the source `savewatch` uploads from, which is why the flag is explicitly set rather than + // left alone — `ClientInit_SetNoSaveFlag` is the only other writer and it only ever clears. + // + // Using the engine's own serializer and its own timer, rather than hooking either, is + // deliberate: it is the code path Blizzard shipped and tested, it already fires far more often + // than the PC build's 5.5 minutes (~10s for a changed character, ~45s otherwise), and it costs + // no guesses about a Mach-O function's arguments. const enabled: *u32 = @ptrFromInt(loaded.at(addr.save_to_file_enabled)); - enabled.* = 0; + enabled.* = 1; +} + +/// Read back what the engine last wrote for `charname`. 0 if there is no save yet, or it does not +/// fit — a short read is never returned, because a truncated save uploaded anywhere becomes a +/// corrupt character. +pub fn readEngineSave(charname: []const u8, out: []u8) usize { + var path: [1024]u8 = undefined; + const p = savePathFor(charname, &path) orelse return 0; + const open: *const fn ([*:0]const u8, [*:0]const u8) callconv(.c) ?*anyopaque = + @ptrFromInt(image.at(addr.open_file)); + const f = open(p.ptr, "rb") orelse return 0; + defer _ = fclose(f); + const n = fread(out.ptr, 1, out.len, f); + // Exactly filling the buffer cannot be told apart from a save too big for it, and the engine + // itself only ever reads 0x2000, so a save that large is not one worth carrying either way. + if (n == 0 or n == out.len) return 0; + return n; } /// Standing in for `CLIENT_LoadCharacterSave`; `game`/`client` are live pointers, not image @@ -165,22 +192,35 @@ fn hand(client: u32, charname: []const u8) bool { return false; } -/// Have the save for a character the realm is sending ready before that client arrives. Returns -/// false if the realm has no such character, which is the answer that keeps the join from being -/// accepted and then failing with reason 0x0e. -pub fn place(account: []const u8, charname: []const u8) bool { +/// Have the save for a character the realm is sending ready before that client arrives. +/// +/// Returns the store VERSION the save was read at, or null if the realm has no such character — +/// which is the answer that keeps the join from being accepted and then failing with reason 0x0e. +/// The version goes on to fence every save this session makes, so it is carried out of here rather +/// than re-read later: a second read could see a different number than the bytes came with. +pub fn place(account: []const u8, charname: []const u8) ?u64 { if (charname.len >= name_max) { note("d2gs-native: chardb name too long: \"{s}\"\n", .{charname}); - return false; + return null; } + // Whatever this server wrote for this character LAST time it played here is still on disk, and + // it is now stale: the realm's copy is the truth, and it may have moved on somewhere else + // entirely. It has to go before anything else happens, in BOTH modes. + // + // In file mode that is obvious. In memory mode it used to be skipped, and once the save + // watcher started uploading what it found on disk that became a guaranteed rollback on every + // rejoin: the engine has not written a new save yet, so the first poll after the join reads + // the OLD file and pushes it over the newer one the realm was holding. + clearFile(charname); + var save: [max_save]u8 = undefined; - const n = fetch(account, charname, &save); - if (n == 0) { + const loaded = store.getCharVersioned(account, charname, &save); + if (loaded.len == 0) { note("d2gs-native: chardb no save for {s}/{s}\n", .{ account, charname }); - if (from_file) clearFile(charname); - return false; + return null; } - return if (from_file) writeFile(charname, save[0..n]) else keep(charname, save[0..n]); + const ok = if (from_file) writeFile(charname, save[0..loaded.len]) else keep(charname, save[0..loaded.len]); + return if (ok) loaded.ver else null; } /// Park the bytes under the character's name, replacing anything left over from an earlier join by diff --git a/apps/d2gs-native/realm.zig b/apps/d2gs-native/realm.zig index 199f96dd..d339d255 100644 --- a/apps/d2gs-native/realm.zig +++ b/apps/d2gs-native/realm.zig @@ -11,6 +11,8 @@ const std = @import("std"); const macho = @import("macho"); const chardb = @import("chardb.zig"); +const seats = @import("gs_seats"); +const dedupe = @import("savededupe.zig"); const store = @import("store.zig"); const health = @import("health.zig"); const p = @import("realm_proto").protocol; @@ -664,6 +666,55 @@ fn runCreate(slot: *Slot) void { req_result = p.CREATE_OK; } +/// How often the engine's saves are collected. The engine rewrites a changed character about +/// every 10 seconds, so polling faster only re-reads the same bytes; `savededupe` would drop them +/// anyway, but the read is the cost. +const save_poll_ms: i64 = 2000; +var last_poll_ms: i64 = 0; + +fn uploadOne(_: void, charname: []const u8, account: []const u8) void { + var buf: [chardb.max_save]u8 = undefined; + const n = chardb.readEngineSave(charname, &buf); + if (n == 0) return; // no save written yet, or one too big to be real + if (!dedupe.shouldUpload(charname, buf[0..n])) return; + // Fenced against the version this character was loaded at: the store takes these bytes only if + // nothing has written the character since. The engine's file on disk is a snapshot of THIS + // server's session, and without the fence a stale one could land on a newer save made + // somewhere else. + switch (store.putCharFenced(account, charname, buf[0..n], seats.version(charname))) { + .stored => |ver| { + seats.setVersion(charname, ver); + note("d2gs-native: saved {s}/{s} ({d} bytes, v{d})\n", .{ account, charname, n, ver }); + }, + .stale => |cur| { + // The store moved on: another server, an admin, an import. Our file is the OLD copy. + // Refusing is correct, and it is recorded as uploaded so we stop offering it — the + // next save the engine writes will be built from live state, not from this file. + note("d2gs-native: save REFUSED as stale for {s}/{s} — store at v{d}, ours v{d}\n", .{ account, charname, cur, seats.version(charname) }); + }, + .unavailable => { + // The bytes stay on disk and `savededupe` is told to forget them, so the next poll + // tries again — a store that blinks costs a delay, not a character. + note("d2gs-native: save DEFERRED for {s}/{s} — the store did not answer, will retry\n", .{ account, charname }); + dedupe.forget(charname); + }, + } +} + +/// Collect what the engine has written for every character this server is responsible for. +/// +/// The engine persists on its own timer into `.d2s` and nothing reads those +/// files back, so this is the step that turns a played session into a character the realm has. +/// Polling rather than hooking the engine's save is on purpose: it uses the serializer Blizzard +/// shipped, on the schedule Blizzard shipped, and needs no assumptions about a Mach-O function's +/// arguments. +fn collectSaves() void { + const now = nowMs(); + if (now - last_poll_ms < save_poll_ms) return; + last_poll_ms = now; + seats.forEachRemembered({}, uploadOne); +} + /// Publish this server, then take create/join from its own queue. There is nothing to connect to: /// the realm is reached through the store, so an instance restarting is not an event here. fn thread() void { @@ -701,6 +752,10 @@ fn thread() void { if (size > n or size < p.HEADER_LEN) continue; onPacket(typ, seq, buf[p.HEADER_LEN..size]); } + // The seat table has no clock of its own off Windows; this loop is it. Only the eviction + // ordering and the join TTL read it, so 20ms of granularity is ample. + seats.advanceClock(20); + collectSaves(); _ = usleep(20_000); } } @@ -750,7 +805,7 @@ fn onCreateGame(seq: u32, body: []const u8) void { copyz(&req_name, readCStr(body, &off)); _ = readCStr(body, &off); // password: the realm already matched it copyz(&req_desc, readCStr(body, &off)); - req_flags = gameFlags(body[2], body[1] != 0, body[3] != 0); + req_flags = gameFlags(body[2], body[1] != 0, body[3] != 0, body[0] != 0); req_done.store(false, .release); req_armed_ms = nowMs(); @@ -791,7 +846,29 @@ fn onJoinGame(seq: u32, body: []const u8) void { var seated = false; if (charname.len > 0 and account.len > 0) { - seated = chardb.place(account, charname); + // Remember WHICH ACCOUNT owns this character before fetching anything. Every save this + // server later writes is keyed by it, and this packet is the only place it is ever + // stated — the GAMELOGON that follows carries the name alone. + // Refused rather than served if we cannot keep it: a join admitted without an account is + // a session whose saves are silently lost, which is worse than a join that failed. The + // reply below still goes out — dropping it would leave the realm waiting on a join that + // never answered. + const tracked = seats.remember(0, gid, charname, account, ""); + if (!tracked) { + note("d2gs-native: cannot track {s}/{s} — refusing the join rather than losing its saves\n", .{ account, charname }); + } + if (tracked) { + if (chardb.place(account, charname)) |ver| { + seated = true; + // Hold the account for as long as the character is here (released on leave, which + // lets the seat be recycled without ever taking it from somebody still playing), + // and remember the version these bytes came at — every save this session makes is + // fenced against it. + seats.seat(charname); + seats.setVersion(charname, ver); + dedupe.forget(charname); + } + } // The realm has placed this character in a game, which is what makes releasing whatever // seat it still holds ELSEWHERE legitimate — see `takeVouch`. // Only when the game is actually known. A vouch recorded with a zero target authorises @@ -840,12 +917,15 @@ fn sendCloseGame(gid: u32) void { } /// Bit 2 gates the per-frame client update and the engine asserts without it; bits 12-14 are the -/// difficulty. Same set `apps/d2gs/engine/server.zig` builds, and the same one the 0x67 probe used. -fn gameFlags(difficulty: u8, expansion: bool, hardcore: bool) u32 { +/// difficulty; bit 21 is ladder, which the join gate matches against the character's own status. +/// Same set `packages/d2engine/gameflags.zig` builds — kept local only because this server does not +/// link the engine package. +fn gameFlags(difficulty: u8, expansion: bool, hardcore: bool, ladder: bool) u32 { var f: u32 = @as(u32, difficulty & 7) << 12; f |= 0x04; if (expansion) f |= 0x10_0000; if (hardcore) f |= 0x800; + if (ladder) f |= 0x20_0000; return f; } @@ -981,6 +1061,7 @@ test "host:port parses into octets and a port" { } test "game flags carry difficulty, the client-update gate and expansion" { - try std.testing.expectEqual(@as(u32, 0x10_0004), gameFlags(0, true, false)); - try std.testing.expectEqual(@as(u32, 0x10_2804), gameFlags(2, true, true)); + try std.testing.expectEqual(@as(u32, 0x10_0004), gameFlags(0, true, false, false)); + try std.testing.expectEqual(@as(u32, 0x10_2804), gameFlags(2, true, true, false)); + try std.testing.expectEqual(@as(u32, 0x30_2804), gameFlags(2, true, true, true)); } diff --git a/apps/d2gs-native/savededupe.zig b/apps/d2gs-native/savededupe.zig new file mode 100644 index 00000000..ea118fd6 --- /dev/null +++ b/apps/d2gs-native/savededupe.zig @@ -0,0 +1,206 @@ +//! Which character saves have already been sent to the store, so identical ones are not resent. +//! +//! The Mac engine persists a character by writing `.d2s` on its own timer — +//! roughly every 10 seconds for a character that changed and every 45 otherwise. Nothing reads +//! those files back (loads come from the realm), so this server watches them instead and uploads +//! what it finds. Polling a file cannot tell "written again" from "written again with the same +//! bytes", and the engine rewrites unchanged saves, so without a memory of what was last sent +//! every seated player would push a redundant save to redis every 45 seconds and mark themselves +//! dirty for the realm's flush worker — turning an idle server into a steady write load on +//! Postgres for no new information. +//! +//! Deliberately NOT an mtime check. A save rewritten with identical content still gets a new +//! mtime, and a save written twice inside the filesystem's timestamp granularity does not — the +//! first is a wasted upload and the second is a LOST one, which is the direction that costs a +//! player their progress. The bytes are the only honest answer, so the fingerprint is taken over +//! the bytes. +//! +//! Pure: it is handed bytes and answers yes or no. Reading files and talking to redis belong to +//! the caller, which is what lets the rule below be tested without an engine or a store. + +const std = @import("std"); + +/// One seat's worth of characters; the engine admits eight clients per game and this server hosts +/// several games, so this is sized to hold every character that can be in a world at once. A +/// character that does not fit is uploaded EVERY time rather than dropped — forgetting costs +/// bandwidth, and the alternative costs saves. +pub const capacity = 64; + +const name_max = 24; + +/// FNV-1a over the save. Not a checksum against corruption — the .d2s carries its own — just a +/// cheap way to notice that these are the same bytes as last time. +fn fingerprint(bytes: []const u8) u64 { + var h: u64 = 0xcbf2_9ce4_8422_2325; + for (bytes) |c| { + h ^= c; + h *%= 0x0000_0100_0000_01b3; + } + return h; +} + +const Entry = struct { + name: [name_max]u8 = @splat(0), + len: usize = 0, + /// Size and hash together: a hash alone is a collision away from silently dropping a save. + bytes_len: usize = 0, + hash: u64 = 0, + used: bool = false, + + fn nameSlice(self: *const Entry) []const u8 { + return self.name[0..self.len]; + } +}; + +var entries: [capacity]Entry = @splat(.{}); +/// Round-robin replacement for a full table. There is no better policy available here and it does +/// not need one: the cost of evicting the wrong entry is one redundant upload. +var next: usize = 0; + +fn eqlIgnoreCase(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + for (a, b) |x, y| { + if (std.ascii.toLower(x) != std.ascii.toLower(y)) return false; + } + return true; +} + +/// Should these bytes be sent to the store? +/// +/// True the first time a character is seen, and thereafter only when its save actually differs +/// from the last one accepted. Recording happens here rather than in a separate call so there is +/// no path where a caller asks and forgets to tell us what it did. +/// +/// An empty save is never worth sending: it is a file the engine has opened and not yet finished +/// writing, and storing it would replace a character with nothing. +pub fn shouldUpload(charname: []const u8, bytes: []const u8) bool { + if (charname.len == 0 or charname.len > name_max or bytes.len == 0) return false; + const h = fingerprint(bytes); + for (&entries) |*e| { + if (!e.used or !eqlIgnoreCase(e.nameSlice(), charname)) continue; + if (e.bytes_len == bytes.len and e.hash == h) return false; + e.bytes_len = bytes.len; + e.hash = h; + return true; + } + // First sight of this character. Claim a slot, preferring a free one. + const slot = for (&entries) |*e| { + if (!e.used) break e; + } else blk: { + const e = &entries[next % entries.len]; + next +%= 1; + break :blk e; + }; + slot.* = .{ .used = true, .bytes_len = bytes.len, .hash = h, .len = charname.len }; + @memcpy(slot.name[0..charname.len], charname); + return true; +} + +/// Forget a character, so its next save is uploaded whatever it contains. +/// +/// Called when a player leaves. Their save has to be re-sent on the next session even if it is +/// byte-identical to the one we remember, because the realm may have been handed a different copy +/// in between — an admin edit, a restore, another server. Remembering across sessions would make +/// this server refuse to correct that. +pub fn forget(charname: []const u8) void { + for (&entries) |*e| { + if (e.used and eqlIgnoreCase(e.nameSlice(), charname)) e.* = .{}; + } +} + +pub fn resetForTest() void { + entries = @splat(.{}); + next = 0; +} + +// ── tests ──────────────────────────────────────────────────────────────────── + +const testing = std.testing; + +test "a character's first save is always uploaded" { + resetForTest(); + try testing.expect(shouldUpload("Persist", "aaaa")); +} + +test "the same bytes are not uploaded twice" { + resetForTest(); + try testing.expect(shouldUpload("Persist", "aaaa")); + try testing.expect(!shouldUpload("Persist", "aaaa")); + try testing.expect(!shouldUpload("Persist", "aaaa")); +} + +test "changed bytes are uploaded, and then not again" { + resetForTest(); + try testing.expect(shouldUpload("Persist", "aaaa")); + try testing.expect(shouldUpload("Persist", "aaab")); + try testing.expect(!shouldUpload("Persist", "aaab")); + // ...including a change back to something seen before: only the LAST accepted save is + // remembered, because that is the one the store holds. + try testing.expect(shouldUpload("Persist", "aaaa")); +} + +test "a change of length alone counts, even if the hash somehow did not" { + resetForTest(); + try testing.expect(shouldUpload("Persist", "aaaa")); + try testing.expect(shouldUpload("Persist", "aaaaa")); +} + +test "characters are tracked independently and matched case-insensitively" { + resetForTest(); + try testing.expect(shouldUpload("Alice", "x")); + try testing.expect(shouldUpload("Bob", "x")); // same bytes, different character + try testing.expect(!shouldUpload("alice", "x")); + try testing.expect(!shouldUpload("ALICE", "x")); +} + +test "an empty save is never uploaded" { + // A file the engine has created and not finished writing. Storing it would replace a + // character with nothing, which is the one outcome worse than a missed save. + resetForTest(); + try testing.expect(!shouldUpload("Persist", "")); + // ...and it must not be remembered either, or the real save that follows would be skipped. + try testing.expect(shouldUpload("Persist", "aaaa")); +} + +test "a name that cannot be tracked is refused rather than half-recorded" { + resetForTest(); + try testing.expect(!shouldUpload("", "aaaa")); + try testing.expect(!shouldUpload("a" ** (name_max + 1), "aaaa")); +} + +test "leaving forgets the character, so the next session re-uploads" { + // The realm may have been handed a different copy while the player was away; remembering + // across sessions would make this server refuse to correct it. + resetForTest(); + try testing.expect(shouldUpload("Persist", "aaaa")); + try testing.expect(!shouldUpload("Persist", "aaaa")); + forget("Persist"); + try testing.expect(shouldUpload("Persist", "aaaa")); +} + +test "a full table keeps working, at worst by re-uploading" { + resetForTest(); + var nb: [8]u8 = undefined; + for (0..capacity) |i| { + const n = std.fmt.bufPrint(&nb, "c{d}", .{i}) catch unreachable; + try testing.expect(shouldUpload(n, "aaaa")); + try testing.expect(!shouldUpload(n, "aaaa")); + } + // One past capacity evicts somebody, which costs a redundant upload and nothing else. What + // must NOT happen is the newcomer being dropped. + try testing.expect(shouldUpload("overflow", "aaaa")); + try testing.expect(!shouldUpload("overflow", "aaaa")); +} + +test "every distinct save of a long session is uploaded exactly once" { + resetForTest(); + var buf: [64]u8 = undefined; + var uploads: usize = 0; + for (0..200) |i| { + // The engine rewrites the save on its timer whether or not it changed; here it changes + // every fifth write. + const save = std.fmt.bufPrint(&buf, "save-{d}", .{i / 5}) catch unreachable; + if (shouldUpload("Persist", save)) uploads += 1; + } + try testing.expectEqual(@as(usize, 40), uploads); +} diff --git a/apps/d2gs-native/store.zig b/apps/d2gs-native/store.zig index 8cb28809..db966c70 100644 --- a/apps/d2gs-native/store.zig +++ b/apps/d2gs-native/store.zig @@ -248,6 +248,78 @@ pub fn getChar(account: []const u8, charname: []const u8, out: []u8) usize { }; } +/// A character as this server took it: the bytes and the store version they were at. +pub const Loaded = struct { len: usize, ver: u64 }; + +/// The store's current version for a character, 0 if it has none. +pub fn charVersion(account: []const u8, charname: []const u8) u64 { + var vb: [192]u8 = undefined; + const verkey = std.fmt.bufPrint(&vb, "realmd:charver:{s}/{s}", .{ account, charname }) catch return 0; + const r = cmd(&.{ "GET", verkey }) orelse return 0; + return switch (r) { + .bulk => |b| blk: { + const v = b orelse break :blk 0; + break :blk std.fmt.parseInt(u64, v, 10) catch 0; + }, + else => 0, + }; +} + +/// Read a character and the version it is at. The VERSION FIRST — see gs_store.getCharVersioned: +/// the other order lets a save landing between the two reads be overwritten by bytes built from +/// it, which is precisely the rollback the fence exists to stop. +pub fn getCharVersioned(account: []const u8, charname: []const u8, out: []u8) Loaded { + const ver = charVersion(account, charname); + return .{ .len = getChar(account, charname, out), .ver = ver }; +} + +/// What happened to a fenced save. Mirrors `gs_store.SaveResult`; the two servers speak to the +/// same store and must agree on what a refusal means. +pub const SaveResult = union(enum) { + stored: u64, + /// The store has a newer copy. These bytes are obsolete and writing them would be a rollback. + stale: u64, + /// Nothing is known and nothing was written. Worth retrying. + unavailable, +}; + +/// Store a character save, but only if nothing has written it since we loaded it. +/// +/// The compare-set-increment runs inside redis, so nothing can interleave between the check and +/// the write. A current version of ZERO is accepted whatever `expect` says: it means the store has +/// no version for this character — never saved, or a redis that lost its state and was refilled +/// from postgres — and in both cases a live session's bytes are the newest thing there is. +pub fn putCharFenced(account: []const u8, charname: []const u8, save: []const u8, expect: u64) SaveResult { + var kb: [192]u8 = undefined; + const key = std.fmt.bufPrint(&kb, "realmd:char:{s}:{s}", .{ account, charname }) catch return .unavailable; + var vb: [192]u8 = undefined; + const verkey = std.fmt.bufPrint(&vb, "realmd:charver:{s}/{s}", .{ account, charname }) catch return .unavailable; + var sb: [192]u8 = undefined; + const setkey = std.fmt.bufPrint(&sb, "realmd:chars:{s}", .{account}) catch return .unavailable; + var mb: [192]u8 = undefined; + const member = std.fmt.bufPrint(&mb, "{s}/{s}", .{ account, charname }) catch return .unavailable; + var eb: [24]u8 = undefined; + const expect_s = std.fmt.bufPrint(&eb, "{d}", .{expect}) catch return .unavailable; + + const script = + \\local cur = tonumber(redis.call('GET', KEYS[2]) or '0') + \\if cur ~= 0 and cur ~= tonumber(ARGV[1]) then return -(cur + 1) end + \\redis.call('SET', KEYS[1], ARGV[4]) + \\redis.call('SADD', KEYS[3], ARGV[2]) + \\redis.call('SADD', KEYS[4], ARGV[3]) + \\return redis.call('INCR', KEYS[2]) + ; + const r = cmdBig(&.{ + "EVAL", script, "4", key, + verkey, setkey, "realmd:dirty", + expect_s, charname, member, + }, save) orelse return .unavailable; + return switch (r) { + .int => |v| if (v > 0) .{ .stored = @intCast(v) } else .{ .stale = @intCast(-v - 1) }, + else => .unavailable, + }; +} + /// Store a character and mark it for the realm's flush worker. Both or neither: a save redis took /// but nobody was told about would sit there while Postgres fell behind. pub fn putChar(account: []const u8, charname: []const u8, bytes: []const u8) bool { diff --git a/apps/d2gs/d2gs.zig b/apps/d2gs/d2gs.zig index 4c118a5f..84dfcdab 100644 --- a/apps/d2gs/d2gs.zig +++ b/apps/d2gs/d2gs.zig @@ -19,6 +19,7 @@ const headless = @import("runtime/feature/headless.zig"); // server_ready flag f const health = @import("runtime/feature/health.zig"); // hacky in-process HTTP health endpoint const gsport = @import("runtime/gsport.zig"); const gamereap = @import("runtime/gamereap.zig"); +const autosave = @import("runtime/autosave.zig"); const roominit = @import("runtime/roominit.zig"); const itemroll = @import("runtime/itemroll.zig"); const gameloop = @import("runtime/gameloop.zig"); @@ -320,6 +321,18 @@ fn serverThread(_: ?*anyopaque) callconv(.winapi) DWORD { if (v > 0) gamereap.applyConfigured(v) else gamereap.applyDefault(); } else gamereap.applyDefault(); } + // How much play a crash is allowed to cost. The engine autosaves a character every 8192 game + // frames (~5.5 min) and otherwise only on a clean leave, so a game shorter than that never + // saves at all and a server lost mid-session hands the player back where they were minutes + // ago. Shortened here to ~20s; an unchanged character still costs nothing, because the engine + // compares the save it built against the one the realm gave it and skips the store. + { + var tmp: [16]u8 = undefined; + if (flagToken("autosave-frames", &tmp) orelse envToken("D2GS_AUTOSAVE_FRAMES", &tmp)) |n| { + const v = std.fmt.parseInt(u32, tmp[0..n], 10) catch 0; + if (v > 0) autosave.applyConfigured(v) else autosave.applyDefault(); + } else autosave.applyDefault(); + } // Per-game server hook surface: hook RoomInit to fan out roomInit() with a real // per-game GameCtx (the game's own FOG pool). Opt-in via a consumer flag so the // default server path stays byte-identical. @@ -401,6 +414,9 @@ fn serverThread(_: ?*anyopaque) callconv(.winapi) DWORD { idle_ticks +%= 1; if (idle_ticks % IDLE_TICKS_PER_SAFETY == 0) server.tick(); // ~1 Hz safety tick (accept + reap) } + // Hand back any save the store refused earlier. Nothing to do on a healthy server, and on + // an unhealthy one this is the difference between a blip and a lost session. + _ = gsredis.retryPending(); health.tick(); // heartbeat for the health endpoint (liveness = this advancing) poolstat.report(); // says who holds the 8 pool managers, but only when that changes // With games live, wait for the frame the engine is actually going to run: both diff --git a/apps/d2gs/engine/realm.zig b/apps/d2gs/engine/realm.zig index 388df3bc..2b91bf66 100644 --- a/apps/d2gs/engine/realm.zig +++ b/apps/d2gs/engine/realm.zig @@ -15,11 +15,12 @@ const server = @import("server.zig"); const cb = @import("d2engine").callbacks; const hostapi = @import("d2engine").hostapi; const gsredis = @import("gs_store"); -const joinctx = @import("../realmclient/joinctx.zig"); +const joinctx = @import("gs_seats"); const obs = @import("obs"); -const patch = @import("../runtime/patch.zig"); const log = @import("../log.zig"); +extern "kernel32" fn GetSystemTimeAsFileTime(lpSystemTimeAsFileTime: *[2]u32) callconv(.winapi) void; + /// The table we register via SetupAsBnetServer. pub var table: server.BnetServerService = .{}; @@ -34,8 +35,13 @@ pub var table: server.BnetServerService = .{}; const OnDatabaseCharacterReceived: *const hostapi.SendDatabaseCharacterFn = @ptrFromInt(hostapi.sendDatabaseCharacter(.v114d).?.address); -var load_filetime: [2]u32 = .{ 0, 0 }; // a zeroed FILETIME (load-time placeholder) -var load_filetimes: [2]u32 = undefined; // { &load_filetime, unk0x194 } +// Arg 7 of CLIENT_OnDatabaseCharacterReceived is a POINTER TO the two dwords of a FILETIME, not +// a pointer to a pointer: @0x5307e8 the engine does `eax=[ebp+0x20]; [edi+0x190]=[eax]; +// [edi+0x194]=[eax+4]`, so both dwords land verbatim in pClient->pFileTime. That timestamp is the +// left-hand side of the CompareFileTime in CalculateGetFlags @0x569d80: a ladder character joins +// only while its save reads NEWER than the realm's stored copy, which for a single-authority store +// is always true. Filled per delivery with "now". +var load_filetime: [2]u32 = .{ 0, 0 }; // Pending character delivery. fpGetDatabaseCharacter is meant to be async, but calling // OnDatabaseCharacterReceived synchronously runs it before SrvJoinGame's ClientSetDwSaveTo1 and @@ -85,7 +91,8 @@ fn getDatabaseCharImpl(ecx: usize, edx: usize, client_id: usize, account: usize) // The engine never fills the account on this path, so resolve it from the // join context the realm sent with JOINGAME (keyed by the joining char name), // and write it back into pClient->szAccName so the rest of the engine has it. - const acct_name = joinctx.accountForChar(char_name) orelse + var acct_buf: [joinctx.max_account]u8 = undefined; + const acct_name = joinctx.accountForChar(char_name, &acct_buf) orelse std.mem.sliceTo(sz_acct, 0); // fall back to whatever the engine had if (acct_name.len > 0 and acct_name.ptr != sz_acct) { const n = @min(acct_name.len, 63); @@ -114,12 +121,21 @@ fn getDatabaseCharImpl(ecx: usize, edx: usize, client_id: usize, account: usize) // fetch was never reached when the dial failed, and it read as a broken store. var sp = obs.enter("char_fetch"); defer sp.exit(); - save_len = gsredis.getChar(acct_name, char_name, &slot.save); + // Versioned: every save this session makes is fenced against the version these bytes came + // at, so a save built from them can never land on top of somebody else's newer ones. + const loaded = gsredis.getCharVersioned(acct_name, char_name, &slot.save); + save_len = loaded.len; + if (save_len > 0) joinctx.setVersion(char_name, loaded.ver); } + // The character is now this server's to save. Hold its account/char mapping until the engine + // reports it gone: every save for the rest of the session is keyed by that account, and an + // entry recycled from under a seated player sends their saves to a key nothing reads. + if (save_len > 0) joinctx.seat(char_name); + // Queue the delivery for the tick loop; do NOT call OnDatabaseCharacterReceived // synchronously here (see Pending). - load_filetimes = .{ @truncate(@intFromPtr(&load_filetime)), 0 }; + GetSystemTimeAsFileTime(&load_filetime); slot.client_id = @intCast(client_id); slot.container = container; slot.len = if (save_len > 0 and save_len <= 0xFFFF) @intCast(save_len) else 0; @@ -138,25 +154,50 @@ pub fn pumpDelivery() void { defer slot.busy.store(false, .release); if (slot.len == 0) { log.print("realm: char fetch FAILED — refusing join"); - _ = OnDatabaseCharacterReceived(slot.client_id, &slot.save, 0, 0, 1, 0, &load_filetimes, @intFromPtr(slot.container)); + _ = OnDatabaseCharacterReceived(slot.client_id, &slot.save, 0, 0, 1, 0, &load_filetime, @intFromPtr(slot.container)); continue; } - _ = OnDatabaseCharacterReceived(slot.client_id, &slot.save, slot.len, slot.len, 0, 0, &load_filetimes, @intFromPtr(slot.container)); + _ = OnDatabaseCharacterReceived(slot.client_id, &slot.save, slot.len, slot.len, 0, 0, &load_filetime, @intFromPtr(slot.container)); log.print("realm: char delivered (SendStateCommand 2)"); } } pub const getDatabaseCharShim = cb.Shim(cb.v114d, .fpGetDatabaseCharacter, getDatabaseCharImpl).shim; +/// Read a NUL-terminated engine string without trusting it to be terminated. Anything the engine +/// hands us as `char*` is read this way: a missing terminator would otherwise walk off the end of +/// its buffer, and the strings here decide where a save is written. +fn boundedCStr(ptr: usize, max: usize) []const u8 { + if (ptr == 0) return ""; + const p: [*]const u8 = @ptrFromInt(ptr); + var i: usize = 0; + while (i < max) : (i += 1) { + if (p[i] == 0) return p[0..i]; + } + return ""; +} + // fpSaveDatabaseCharacter (slot 0x0C). Called by SaveAllPlayers @0x52ca10 -> SaveGameAllGameTypes // @0x532400 -> SaveToFileBnet @0x531eb0 whenever the save CHANGED (~8192 frames / 5.5 min, or on // leave/disconnect). __fastcall ECX+EDX+4 stack (6 args), ret 0x10 (confirmed by disasm + runtime -// arg dump): ECX=&realmId, EDX/s1=name strings, s2=&{u16 size; .d2s} (size=.d2s_len+2, .d2s at -// s2+2), s3=total size, s4=client container. Char name is at .d2s offset 0x14 (16 bytes) after -// validating the 0xaa55aa55 signature; account comes from the join context. Outbound-only — safe -// to run synchronously on the tick thread. +// arg dump). The call site is 0x53220d and sets up: +// +// ECX = &nRealmId a local COPY of the realm id on SaveToFileBnet's own frame. It is NOT +// &pClient->pRealm, so the ECX-0x4B / ECX-0x5B trick that reads the +// client's names in fpGetDatabaseCharacter does not work here. +// EDX = char* the character name +// s1 = char* THE ACCOUNT NAME — a local buffer SaveToFileBnet filled from +// GetAccountName @0x5392f0 (`SStrCopy(szText, pClient->szAccName, 0x32)`). +// s2 = &{u16 size; .d2s} size = .d2s_len + 2, the save itself at s2+2 +// s3 = total size +// s4 = client container pClient->pClientContainer (offset 0x60) by value — an opaque BNet +// handle, not a pointer into D2ClientStrc. +// +// Char name is taken from .d2s offset 0x14 (16 bytes) after validating the 0xaa55aa55 signature +// rather than from EDX, because the save is the thing being written and its own header is what +// names it. Outbound-only — safe to run synchronously on the tick thread. fn saveDatabaseCharImpl(ecx: usize, edx: usize, s1: usize, s2: usize, s3: usize, s4: usize) callconv(.c) usize { - _ = .{ ecx, edx, s1, s3, s4 }; + _ = .{ ecx, edx, s3, s4 }; const buf: [*]const u8 = @ptrFromInt(s2); const total: usize = std.mem.readInt(u16, buf[0..2], .little); if (total < 2 + 0x24) { @@ -170,7 +211,44 @@ fn saveDatabaseCharImpl(ecx: usize, edx: usize, s1: usize, s2: usize, s3: usize, return 0; } const char_name = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(&d2s[0x14])), 0); - const account = joinctx.accountForChar(char_name) orelse char_name; + + // The save is keyed by ACCOUNT, and getting that wrong is invisible: a save written under the + // wrong account is a perfectly good save at an address no login path reads, so the player is + // rolled back to whatever their last correctly-keyed save was and nothing reports a failure. + // This used to fall back to the CHARACTER's own name, writing `realmd:char::`. + // + // Two sources, in this order: + // + // 1. s1, the engine's own account string for THIS CLIENT — a local buffer SaveToFileBnet + // fills from GetAccountName @0x5392f0, i.e. pClient->szAccName, which + // fpGetDatabaseCharacter wrote the realm's account into on the way in. Our own value + // handed back by the party that has held it all session. + // 2. the seat table, for a save whose JOINGAME we have but whose client field is empty. + // + // Neither: DROP the save and say so. There is no third guess that is not a lie. + // + // The per-client field goes FIRST because it is the only one of the two that is unambiguous. + // This realm does not make character names globally unique — the create-time check is scoped + // to one account — so a lookup BY NAME can return the wrong player's account entirely, which + // is not a rollback but a character swap. `gs_seats` refuses to seat two accounts' same-named + // characters at once precisely so its answer stays usable as the fallback, but the engine's + // own per-client field needs no such rule. + // + // `s1`'s identity is not taken on trust: it is the account argument in pvpgn's 1.09d game + // server header, it is what 1.14d's SaveGameAllGameTypes passes (`SaveToFileBnet(pGame, + // szAccountName, pUnit, szCharName, ...)`), and it is what the call site at 0x53220d loads. + // The guard below is for the one failure that would be silent anyway: the NEIGHBOURING + // argument is the character name, just as `keyable` as an account, and taking it would key + // every save to `realmd:char::` — the exact bug this path exists to stop. + var acct_buf: [joinctx.max_account]u8 = undefined; + const from_engine = boundedCStr(s1, joinctx.max_account + 1); + const account = if (gsredis.keyable(from_engine) and !std.ascii.eqlIgnoreCase(from_engine, char_name)) + from_engine + else joinctx.accountForChar(char_name, &acct_buf) orelse { + log.print("realm: fpSaveDatabaseCharacter — NO ACCOUNT for this character, save DROPPED"); + log.cstr("realm: char=", @intFromPtr(&d2s[0x14])); + return 1; + }; log.print("realm: fpSaveDatabaseCharacter — persisting char"); log.cstr("realm: char=", @intFromPtr(&d2s[0x14])); @@ -178,8 +256,29 @@ fn saveDatabaseCharImpl(ecx: usize, edx: usize, s1: usize, s2: usize, s3: usize, // Straight to the store, with no realm to dial first — the save is durable the moment it // lands there, and the realm's flush worker moves it to the store of record behind us. - const ok = gsredis.putChar(account, char_name, d2s); - log.print(if (ok) "realm: char saved" else "realm: char save FAILED"); + // Fenced and durable. Fenced: the store accepts these bytes only if nothing has written this + // character since we loaded it, which is what makes a rollback impossible rather than merely + // unlikely. Durable: a store that could not be REACHED parks the save for the tick loop to + // retry, rather than the bytes going back into the engine's buffer never to be offered again. + switch (gsredis.putCharDurable(account, char_name, d2s, joinctx.version(char_name))) { + .stored => |ver| { + joinctx.setVersion(char_name, ver); + log.print("realm: char saved"); + }, + .stale => |cur| { + // Somebody wrote this character after we loaded it — another server, an admin, an + // import. Our bytes are the OLD ones. Refusing them is the correct outcome and the + // whole point of the fence, so this is loud rather than quiet: it should not happen, + // and if it does the character lock is not doing its job. + log.print("realm: char save REFUSED as stale — the store has a newer copy, not overwriting"); + log.hex("realm: store version is 0x", @as(usize, @intCast(cur))); + log.hex("realm: ours was 0x", @as(usize, @intCast(joinctx.version(char_name)))); + }, + .unavailable => { + log.print("realm: char save deferred — the store did not answer, queued for retry"); + log.hex("realm: saves waiting 0x", gsredis.pending()); + }, + } return 1; } @@ -203,32 +302,66 @@ fn getFileTimeStub() callconv(.naked) void { ); } +// fpSetGameData (slot 0x34). Called once per game from SetGameDataHook @0x005379a0, during +// GAME_CreateBattleNetGame. Takes NO arguments (the call site pushes nothing and ECX is zero) and +// its return value is stored straight into pGame+0x20: `mov [esi+0x20], eax` @0x005379c4. +// +// That field is called pBnetGameData, which is a misnomer — nothing ever dereferences it. Its only +// readers are eight `RANDOM_RandomNumberSelector((D2SeedStrc*)&pGame->pBnetGameData, nRoll)` calls +// in the item drop path (Drop.cpp), which treat {+0x20, +0x24} as a 64-bit LCG state: +// `state = state.lo * 0x6ac690c5 + state.hi`, result = state mod range (RANDOM_RandomNumberSelector +// @0x0045c3e0). So this slot seeds the stream that decides what quality every item in the game +// rolls. +// +// Leaving it null does not crash, which is why it went unnoticed: +0x20 stays 0 and +0x24 is +// bGameIsSetup (1), so every game starts the same stream from {0, 1} and rolls the identical +// sequence of qualities as every other game. Seeding it per game is the whole fix. +// +// pvpgn's GS answers with the constant 0x87654321 (d2gs109 callback.c) — which has the same defect, +// just from a different starting point. +var drop_seed_state: u64 = 0; + +/// SplitMix64 — one multiply-xor chain, no state beyond the counter, and well-distributed low bits +/// (which matter here: the engine takes `state mod range` with small ranges). +fn nextDropSeed() u32 { + drop_seed_state +%= 0x9E3779B97F4A7C15; + var z = drop_seed_state; + z = (z ^ (z >> 30)) *% 0xBF58476D1CE4E5B9; + z = (z ^ (z >> 27)) *% 0x94D049BB133111EB; + z = z ^ (z >> 31); + const v: u32 = @truncate(z); + return if (v == 0) 0x9E3779B9 else v; // never hand back the value that means "unseeded" +} + +fn setGameDataImpl() callconv(.c) u32 { + const seed = nextDropSeed(); + log.hex("realm: fpSetGameData — item-drop seed for this game 0x", seed); + return seed; +} + /// Populate the realm callback table. Call before SetupAsBnetServer (i.e. before /// bootstrapRealmServer). Wires the char loader + token validation + leave. pub fn init() void { table.base.fpGetDatabaseCharacter = @ptrCast(&getDatabaseCharShim); table.base.fpSaveDatabaseCharacter = @ptrCast(&saveDatabaseCharShim); table.base.fpLeaveGame = @ptrCast(&leaveGameStub); + table.base.fpSetGameData = @ptrCast(&setGameDataImpl); table.ext.fpGetDatabaseFileTime = @ptrCast(&getFileTimeStub); // 1.14d only, past the shared table enableTokenValidation(); // register fpFindPlayerToken (engine IsBadCodePtr-checks it) - allowLadderAndLadderless(); + // Per PROCESS, not per game: games on one server must not repeat each other's drop stream, and + // two servers started in the same tick must not share one either. + var ft: [2]u32 = .{ 0, 0 }; + GetSystemTimeAsFileTime(&ft); + drop_seed_state = (@as(u64, ft[1]) << 32) | @as(u64, ft[0]); } -// Charon-style "enable ladder + ladderless joins". CalculateGetFlags @0x569d80 runs a closed-realm -// save-freshness / ladder anti-rollback gate (CompareFileTime vs the d2dbs per-char filetime) that -// refuses ladder chars with nReason 0x1a (fpGetDatabaseFileTime returns "oldest"). The gate sits -// behind `if (IsBattleNetServer)` via `JZ 0x569e17`; flip that JZ (74) to JMP (EB) so it's ALWAYS -// skipped — anti-rollback is meaningless for our single-authority store. -fn allowLadderAndLadderless() void { - const addr: usize = 0x00569dc3; // JZ 0x569e17 (74 52) after the IsBattleNetServer CMP - const cur: *const u8 = @ptrFromInt(addr); - if (cur.* == 0x74) { - _ = patch.writeBytes(addr, &[_]u8{0xEB}); // JZ -> JMP: always skip the freshness gate - log.print("realm: ladder gate patched (ladder + ladderless joins enabled)"); - } else { - log.hex("realm: ladder-gate patch SKIPPED, unexpected byte 0x", cur.*); - } -} +// Ladder joins used to be unblocked here by flipping the `JZ 0x569e17` at 0x00569dc3 so +// CalculateGetFlags skipped its whole IsBattleNetServer block. That is not needed any more and is +// deliberately gone: the block refused ladder characters only because we broke its two inputs — +// pClient->pFileTime was a truncated POINTER rather than a timestamp, and the game was created +// without flag bit 21, so a ladder character always met a non-ladder game. Both are supplied +// honestly now, which fixes the same refusal on every other engine too, where 0x00569dc3 is not +// that instruction and a byte patch could never have helped. // fpFindPlayerToken (slot 0x18) // __fastcall: ECX + EDX + 7 stack args, callee-cleanup ret 0x1c, returns int diff --git a/apps/d2gs/realmclient/d2cs.zig b/apps/d2gs/realmclient/d2cs.zig index 1f5ae07d..fca66ebe 100644 --- a/apps/d2gs/realmclient/d2cs.zig +++ b/apps/d2gs/realmclient/d2cs.zig @@ -14,7 +14,7 @@ const std = @import("std"); const p = @import("realm_proto").protocol; const server = @import("../engine/server.zig"); const command = @import("../engine/command.zig"); -const joinctx = @import("joinctx.zig"); +const joinctx = @import("gs_seats"); const redis = @import("gs_store"); const poolstat = @import("../runtime/poolstat.zig"); const log = @import("../log.zig"); @@ -180,6 +180,10 @@ pub fn onGameDestroyed(name: []const u8) void { /// A game we never tracked is one we didn't create, so we have no gameid to name it by /// and stay quiet rather than guess. pub fn onPlayersChanged(name: []const u8, players: u32, joined: bool, char: []const u8, level: u32, class: u32) void { + // A departing player's seat stops being one a later join has to work around. The MAPPING is + // kept — the engine's last save for this character runs after the leave is reported, and + // dropping the account here would lose exactly that save — the entry just becomes reusable. + if (!joined and char.len > 0) joinctx.release(char); const gid = peekGameId(name) orelse return; var buf: [@sizeOf(p.UpdateGameInfo) + 24]u8 = undefined; var r = std.mem.zeroes(p.UpdateGameInfo); @@ -196,7 +200,17 @@ pub fn onPlayersChanged(name: []const u8, players: u32, joined: bool, char: []co const n: usize = @min(char.len, buf.len - @sizeOf(p.UpdateGameInfo) - 1); @memcpy(buf[@sizeOf(p.UpdateGameInfo)..][0..n], char[0..n]); buf[@sizeOf(p.UpdateGameInfo) + n] = 0; // cstr terminator - const total = @sizeOf(p.UpdateGameInfo) + n + 1; + var total = @sizeOf(p.UpdateGameInfo) + n + 1; + // The account too, when we know it. Without it the realm has to free a departing player's + // character seat by NAME, and names are unique only per account here — two "Bob"s in one game + // and it frees the wrong one, out from under somebody still playing. Empty when unknown, which + // the realm reads as "cannot be sure" and leaves the seat for the game-close sweep. + var ab: [joinctx.max_account]u8 = undefined; + const acct = joinctx.accountForChar(char, &ab) orelse ""; + const an: usize = @min(acct.len, buf.len - total - 1); + @memcpy(buf[total..][0..an], acct[0..an]); + buf[total + an] = 0; + total += an + 1; const hdr = p.header(.updategameinfo, @intCast(total), nextSeq()); @memcpy(buf[0..@sizeOf(p.Header)], std.mem.asBytes(&hdr)); emit(buf[0..total]); @@ -239,7 +253,19 @@ fn handleCreateGame(seq: u32, body: []const u8) void { const expansion = body[1] != 0; const difficulty: u3 = @truncate(body[2]); const hardcore = body[3] != 0; - const flags = server.gameFlags(difficulty, expansion, hardcore); + // Ladder is BOTH a create-game argument and flag bit 21: the argument only lands in + // pGame->nLadder, while bit 21 is the one CalculateGetFlags matches against the joining + // character's status and the one the client is told about. Passing it in only one place is + // what refused every ladder character. + const flags = server.gameFlags(difficulty, expansion, hardcore, ladder != 0); + // The kind of game, on the record. Every join refusal downstream is a disagreement between + // these four and the joining character's own .d2s status byte, and the player is only ever + // told "Failed to join game" — so without this line the cause is unrecoverable after the fact. + log.hex("d2cs: CREATEGAME flags=0x", flags); + log.hex("d2cs: ladder=", ladder); + log.hex("d2cs: expansion=", @intFromBool(expansion)); + log.hex("d2cs: hardcore=", @intFromBool(hardcore)); + log.hex("d2cs: difficulty=", difficulty); // Enqueue for the tick thread (engine isn't safe to call from here directly). // The engine writes the server token (= gameid). @@ -275,10 +301,21 @@ fn handleJoinGame(seq: u32, body: []const u8) void { // connects to :4000 (engine calls fpFindPlayerToken). We don't touch the // engine token table here — just remember who is joining so we can resolve // the account (and guild) when the engine asks us for the character save. - if (charname.len > 0 and account.len > 0) { - joinctx.remember(token, gameid, charname, account, guild_tag); - if (guild_tag.len > 0) log.print("d2cs: JOINGAME cached char/account/guild for fetch") else log.print("d2cs: JOINGAME cached char/account for fetch"); + // + // Refused when we cannot keep it. The account is what every save this session is keyed by, + // so a join we admit without one is a session whose saves are silently lost — better to say + // no now than to hand the player a game that quietly rolls them back afterwards. + if (charname.len == 0 or account.len == 0) { + log.print("d2cs: JOINGAME with no char/account — refusing"); + sendJoinGameReply(seq, 1, gameid); + return; + } + if (!joinctx.remember(token, gameid, charname, account, guild_tag)) { + log.print("d2cs: JOINGAME cannot be tracked (name too long, or every seat is in a game) — refusing"); + sendJoinGameReply(seq, 1, gameid); + return; } + if (guild_tag.len > 0) log.print("d2cs: JOINGAME cached char/account/guild for fetch") else log.print("d2cs: JOINGAME cached char/account for fetch"); sendJoinGameReply(seq, if (command.allow_create) 0 else 1, gameid); log.hex("d2cs: JOINGAME ack for gameid=0x", gameid); } diff --git a/apps/d2gs/runtime/autosave.zig b/apps/d2gs/runtime/autosave.zig new file mode 100644 index 00000000..bde07c3b --- /dev/null +++ b/apps/d2gs/runtime/autosave.zig @@ -0,0 +1,93 @@ +//! How often the engine saves a character mid-game. +//! +//! `UpdateClients` @0x52d440 calls `SaveAllPlayers` @0x52ca10 every 8192 game frames — about 5.5 +//! minutes at 25 fps — and that is the only periodic path to the realm's save callback. The others +//! are edges: a clean leave (`NET_D2GS_SERVER_LeaveServer`) and a forced disconnect +//! (`CheckClientTimeouts`). So a server that dies, is rescheduled, or loses a client without a +//! clean leave gives the player back whatever they had up to 5.5 minutes ago, and a game shorter +//! than that never autosaves at all. +//! +//! The interval is a mask, not a counter: the compiler rendered `dwGameFrame % 8192 == 0` as +//! `AND EAX, 0x80001fff` @0x52d45c — the signed-modulo idiom, sign bit plus 8192-1 — followed by a +//! fixup for negative frames (`DEC; OR EAX, 0xffffe000; INC`) and a jump-if-zero. Shrinking the +//! interval is therefore two immediates and no new code, which is why it is done this way rather +//! than by calling `SaveAllPlayers` ourselves: the save then still happens at exactly the point in +//! the tick the engine expects, holding whatever it holds, with no argument or re-entrancy risk of +//! ours. +//! +//! Saving more often is close to free. `SaveToFileBnet` builds the save and compares it against +//! the copy the realm handed it at load; an unchanged character takes the +//! `fpRelockDatabaseCharacter` path and never reaches the store. So the cost of a short interval +//! is paid only by players who actually did something. +//! +//! The frame counter is per game (`pGame->dwGameFrame` @+0xa8), so games do not all save on the +//! same tick — the load is spread by construction. + +const std = @import("std"); +const patch = @import("patch.zig"); +const log = @import("../log.zig"); +/// The mask arithmetic and the shipped-constant proofs live in d2engine, where they are pure and +/// can be asserted on any host; this file is only the patching half. +const interval = @import("d2engine").saveinterval; + +/// imm32 of `AND EAX, 0x80001fff` @0x52d45c (`25 ff 1f 00 80`) — the interval mask. +const MASK_IMM_ADDR: usize = 0x0052d45d; +/// imm32 of `OR EAX, 0xffffe000` @0x52d46a (`0d 00 e0 ff ff`) — the negative-frame fixup that has +/// to agree with the mask. `dwGameFrame` would have to run ~994 days at 25 fps to go negative, so +/// this path is unreachable in practice; it is patched anyway because a mask and a fixup that +/// disagree is a landmine left for whoever reads this next. +const FIXUP_IMM_ADDR: usize = 0x0052d46b; + +/// The instructions as they ship. Checked before patching: an immediate written at an address +/// whose opcode moved is a silent corruption of whatever instruction is there now. +const MASK_INSN: [5]u8 = .{ 0x25, 0xff, 0x1f, 0x00, 0x80 }; +const FIXUP_INSN: [5]u8 = .{ 0x0d, 0x00, 0xe0, 0xff, 0xff }; + +/// 512 frames — about 20 seconds at 25 fps. Chosen against what a rollback costs rather than what +/// a save costs: 20 seconds of lost play is an annoyance, 5.5 minutes is a boss run. +pub const default_frames: u32 = 512; + +/// Set the periodic save interval to `frames`, which MUST be a power of two — the engine tests it +/// with a mask, so anything else would fire on a set of frames rather than an interval. +pub fn apply(frames: u32) void { + if (!interval.isExpressible(frames)) { + log.hex("autosave: REFUSED, interval is not a power of two: 0x", frames); + return; + } + const mask = interval.mask(frames); + const fixup = interval.fixup(frames); + + const mask_ok = patch.MemoryPatch(MASK_IMM_ADDR - 1) + .expect(&MASK_INSN) + .skip(1) + .data(mask) + .commit(); + if (!mask_ok) { + log.print("autosave: FAILED to patch the save interval — leaving the engine's 8192 frames"); + return; + } + // Best-effort: the mask is the one that matters, and a failure here leaves an unreachable + // path inconsistent rather than the save broken. + _ = patch.MemoryPatch(FIXUP_IMM_ADDR - 1) + .expect(&FIXUP_INSN) + .skip(1) + .data(fixup) + .commit(); + + log.hex("autosave: mid-game save interval set to frames 0x", frames); + log.hex("autosave: which is roughly seconds 0x", frames / interval.fps); +} + +pub fn applyDefault() void { + apply(default_frames); +} + +/// `frames` from --autosave-frames / D2GS_AUTOSAVE_FRAMES, else the default. +/// +/// Rounded DOWN to a power of two rather than refused, because the value is a duration to whoever +/// sets it and the power-of-two rule is an artefact of how the engine tests it. Clamped at both +/// ends: below 64 frames (~2.5s) the save stops being periodic and starts being per-action, and +/// above the engine's own 8192 there is no reason to be here at all. +pub fn applyConfigured(frames: u32) void { + apply(interval.round(frames)); +} diff --git a/apps/d2gs/runtime/feature/srvtrace.zig b/apps/d2gs/runtime/feature/srvtrace.zig index 282dac0d..fd79cbd9 100644 --- a/apps/d2gs/runtime/feature/srvtrace.zig +++ b/apps/d2gs/runtime/feature/srvtrace.zig @@ -222,6 +222,15 @@ pub fn serverTick() void { e.int("clients", @as(i32, @bitCast(readU32(pg, 140)))); e.int("players", @as(i32, @bitCast(readU32(pg, 144)))); e.int("monsters", @as(i32, @bitCast(readU32(pg, 148)))); + // The item-drop RNG state. Despite the name the recon gives pGame+0x20 it is not a pointer + // and is never dereferenced: its only readers are the eight + // RANDOM_RandomNumberSelector((D2SeedStrc*)&pGame->pBnetGameData, nRoll) calls in Drop.cpp, + // which advance {+0x20, +0x24} as one 64-bit LCG. +0x20 is whatever fpSetGameData returned + // and +0x24 is bGameIsSetup. Reported here rather than at game_alloc because + // EVENT_AllocTimerQueue runs BEFORE SetGameDataHook, so at creation it always reads zero + // whether the slot is filled or not. A stream that never varies between games is invisible + // in every other way, so it is worth a field. + e.int("dropSeed", @as(i32, @bitCast(readU32(pg, 0x20)))); e.end(); } } @@ -305,6 +314,83 @@ fn onPlayerJoin(pgame: usize, pclient: usize, _: usize) callconv(.c) void { if (ctxFor(pgame)) |ctx| feature.fanPlayerJoin(&ctx, if (pclient == 0) 0 else readU32(pclient, CL_SLOT)); } +/// Why a join was turned away, in the engine's own numbering. The refusal is decided deep inside +/// the char load and only ever reaches the player as "Failed to join game", so without this the +/// server has no record of the cause at all — which is exactly how a report of "ladder characters +/// cannot enter" arrives with nothing to go on. +/// +/// Sources: CalculateGetFlags @0x569d80 raises 0x08-0x0e and 0x19/0x1a; the second, redundant set +/// of the same checks in CLIENT_LoadCharacterAndSendGameData @0x539760 raises 0x13-0x18; and the +/// save parser PLAYERSAVE_ParseHeaderAndCreateUnit @0x56a090 raises 3-7. +fn refusalReason(code: u32) []const u8 { + return switch (code) { + 3 => "client has no character name", + 4 => "save truncated, or bad class id", + 5 => "save size does not match its header", + 6 => "save checksum mismatch", + 7 => "save version out of range, or name does not match the client", + 0x08, 0x18 => "expansion character in a classic game (0x18 also: assassin/druid in a classic game)", + 0x09, 0x17 => "classic character in an expansion game", + 0x0a, 0x15 => "hardcore character that has already died", + 0x0b, 0x14 => "hardcore character in a softcore game", + 0x0c, 0x13 => "softcore character in a hardcore game", + 0x0d => "nightmare not unlocked for this character", + 0x0e => "hell not unlocked for this character", + 0x19 => "ladder character in a non-ladder game (game flag bit 21 clear)", + 0x1a => "non-ladder character in a ladder game (game flag bit 21 set)", + else => "unknown", + }; +} + +/// NET_D2GS_SERVER_Send_0xB4_ConnectionRefused — __fastcall ECX=nClientId, EDX=nReason. The last +/// thing the engine does before the client sees "Failed to join game". +fn onJoinRefused(client_id: usize, reason: usize, _: usize) callconv(.c) void { + var e = ev("join_refused"); + e.int("clientId", trunc32(client_id)); + e.hex("reason", reason); + e.str("why", refusalReason(trunc32(reason))); + e.end(); +} + +/// The save path, observed at its two decision points. +/// +/// Between them these say exactly where a character save stops, which is otherwise invisible: +/// every gate on the way to the realm's `fpSaveDatabaseCharacter` either does nothing or takes a +/// branch, and none of them logs. `save_sweep` fires when the engine decides it is time to save a +/// game's players at all; `save_route` fires once per player and carries the two values that +/// decide where those bytes go. If a player reports lost progress, the two lines and the +/// `fpSaveDatabaseCharacter` line in realm.zig localise it without another RE session: +/// +/// no save_sweep -> the periodic trigger never fired (see runtime/autosave.zig) and +/// no leave or timeout happened either +/// sweep but no save_route -> the client had no player unit; nothing to serialise +/// route with type 1 or 2 -> SaveToBuffer: an open-bnet/LAN game, where the CLIENT owns the +/// save, so it never reaches the realm at all +/// route with callbacks 0 -> SaveToFile: the realm callback table was never registered +/// route otherwise -> SaveToFileBnet, and the next word is realm.zig's +fn onSaveSweep(pgame: usize, _: usize, _: usize) callconv(.c) void { + var e = ev("save_sweep"); + putGame(&e, pgame); + e.end(); +} + +/// `SaveGameAllGameTypes` @0x532400, `__fastcall(pGame, pUnit, ...)`. `nGameType` is the byte at +/// +0x6A — eD2HostGameType, NOT the ladder flag, which is a different field entirely — and the +/// callback table is the global the router tests against null. +const SAVE_GAME_TYPE = 0x6A; +const BNET_SERVICE_PTR: usize = 0x00883d50; + +fn onSaveRoute(pgame: usize, punit: usize, _: usize) callconv(.c) void { + var e = ev("save_route"); + putGame(&e, pgame); + e.int("unit", trunc32(punit)); + e.int("game_type", if (pgame == 0) 0xff else readU8(pgame, SAVE_GAME_TYPE)); + // Read, not assumed: "the table is registered" is exactly the belief that was wrong for every + // server in the fleet. + e.int("callbacks", @as(u32, @intFromBool(@as(*const usize, @ptrFromInt(BNET_SERVICE_PTR)).* != 0))); + e.end(); +} + fn onPlayerLeave(pgame: usize, pclient: usize, _: usize) callconv(.c) void { var e = ev("player_leave"); putGame(&e, pgame); @@ -1543,6 +1629,10 @@ const hooks = [_]Hook{ .{ .addr = 0x52C7F0, .prologue = 7, .label = "game_destroy", .handler = &onGameDestroy, .a1 = .ecx, .a2 = .edx }, .{ .addr = 0x52C410, .prologue = 6, .label = "player_join", .handler = &onPlayerJoin, .game = .ecx, .a1 = .ecx, .a2 = .edx }, .{ .addr = 0x52C500, .prologue = 5, .label = "player_leave", .handler = &onPlayerLeave, .game = .ecx, .a1 = .ecx, .a2 = .edx }, + // The save path. `55 8b ec 83 ec 10` and `55 8b ec 83 ec 58` — six bytes each, no short + // branches. Both take the game in ECX. + .{ .addr = 0x52CA10, .prologue = 6, .label = "save_sweep", .handler = &onSaveSweep, .game = .ecx, .a1 = .ecx }, + .{ .addr = 0x532400, .prologue = 6, .label = "save_route", .handler = &onSaveRoute, .game = .ecx, .a1 = .ecx, .a2 = .edx }, // -- combat -- .{ .addr = 0x57C6C0, .prologue = 6, .label = "damage", .handler = &onDamage, .game = .ecx, .a1 = .edx, .a2 = .{ .stack = 4 }, .a3 = .{ .stack = 0xC } }, @@ -1576,6 +1666,7 @@ const hooks = [_]Hook{ .{ .addr = 0x54C300, .prologue = 7, .label = "cube_transmute", .handler = &onCube, .game = .ecx, .a1 = .edx }, // ECX=pGame EDX=pPlayer .{ .addr = 0x5A5E50, .prologue = 6, .label = "hostility", .handler = &onHostility, .game = .ecx, .a1 = .edx, .a2 = .{ .stack = 4 }, .a3 = .{ .stack = 8 } }, // ECX=pGame EDX=pUnit, [esp+4]=pTarget, [esp+8]=bHostile .{ .addr = 0x5A5BE0, .prologue = 5, .label = "party_invite", .handler = &onPartyInvite, .a1 = .edx, .a2 = .{ .stack = 4 } }, // EDX=inviter, [esp+4]=pTarget + .{ .addr = 0x53B260, .prologue = 6, .label = "join_refused", .handler = &onJoinRefused, .a1 = .ecx, .a2 = .edx }, // ECX=nClientId EDX=nReason // -- decoded player actions (SCMD handlers; ECX=pGame EDX=player, [esp+4]=pkt) -- .{ .addr = 0x549D80, .prologue = 5, .label = "skill_left", .handler = &onSkillLeft, .game = .ecx, .a1 = .edx }, // 0x06 LeftSkillOnEntity diff --git a/apps/d2gs/runtime/patch.zig b/apps/d2gs/runtime/patch.zig index df76eb4a..7b8fea5f 100644 --- a/apps/d2gs/runtime/patch.zig +++ b/apps/d2gs/runtime/patch.zig @@ -6,6 +6,7 @@ //! bytes. Unsynchronised, that lost ~2 boots in 3 (resumed mid-instruction, died reading 0x1a) — //! so a patch is applied whole with every other thread stopped and off the bytes (see quiesce). const std = @import("std"); +const log = @import("../log.zig"); const DWORD = u32; const BYTE = u8; @@ -319,16 +320,37 @@ pub fn revertAll() void { pub const Patch = struct { cursor: usize, ok: bool = true, + /// Set by a failed `expect()`: every later step becomes a no-op and commit() reports failure. + poisoned: bool = false, const Self = @This(); fn step(self: Self, wrote: bool, len: usize) Self { - return .{ .cursor = self.cursor + len, .ok = self.ok and wrote }; + return .{ .cursor = self.cursor + len, .ok = self.ok and wrote, .poisoned = self.poisoned }; + } + + /// Assert what is at the cursor BEFORE overwriting it, and abandon the chain if it differs. + /// + /// Without this a patch is a bare address, and an address that has drifted — a different + /// build, a different engine version, an offset miscounted by one — is not an error here: the + /// write lands on whatever happens to be there and the process runs on, corrupt, with nothing + /// in the log. That is the failure mode this project keeps paying for. Every patch that + /// CHANGES BEHAVIOUR should state the bytes it expects to replace, so a wrong address stops + /// being a silent corruption and becomes one greppable line. + pub fn expect(self: Self, want: []const u8) Self { + const at: [*]const u8 = @ptrFromInt(self.cursor); + if (std.mem.eql(u8, at[0..want.len], want)) return self; + log.hex("patch: REFUSED — unexpected bytes at 0x", self.cursor); + log.hex("patch: wanted first byte 0x", want[0]); + log.hex("patch: found first byte 0x", at[0]); + // Poison the chain: `ok` is false and the cursor is parked, so nothing after this writes. + return .{ .cursor = self.cursor, .ok = false, .poisoned = true }; } /// Raw bytes at the cursor. Staged, not written — commit() applies the whole chain /// at once, so no thread can ever run a partly-rewritten instruction. pub fn bytes(self: Self, b: []const u8) Self { + if (self.poisoned) return self; // an expect() already failed; write nothing const staged = stageWrite(self.cursor, b) or writeBytesProtected(self.cursor, b); return self.step(staged, b.len); } @@ -379,12 +401,17 @@ pub const Patch = struct { return self.nops(addr - self.cursor); } /// Advance the cursor `n` bytes without writing (leave the originals intact). + /// + /// Carries `poisoned`, and that is not incidental: dropping it here let a chain that had + /// already REFUSED an `expect()` go on to write at the address past the skip. The refusal + /// still reported failure from commit(), so the bytes were on the ground and the log said + /// the patch had not been applied — the exact silent corruption `expect` exists to prevent. pub fn skip(self: Self, n: usize) Self { - return .{ .cursor = self.cursor + n, .ok = self.ok }; + return .{ .cursor = self.cursor + n, .ok = self.ok, .poisoned = self.poisoned }; } - /// Move the cursor back `n` bytes. + /// Move the cursor back `n` bytes. Carries `poisoned` for the same reason as `skip`. pub fn rewind(self: Self, n: usize) Self { - return .{ .cursor = self.cursor - n, .ok = self.ok }; + return .{ .cursor = self.cursor - n, .ok = self.ok, .poisoned = self.poisoned }; } // Readable shorthands for common one/two-byte ops (cf. Charon's ASM::*). @@ -416,6 +443,10 @@ pub const Patch = struct { /// Finish the chain: apply everything it staged, with the world stopped, and report /// whether every step succeeded. pub fn commit(self: Self) bool { + if (self.poisoned) { + stage_len = 0; // drop anything staged before the mismatch + return false; + } if (stage_len == 0) return self.ok; stage_ok = true; withWorldStopped(stage_base, stage_len, &applyStaged); diff --git a/apps/d2gs/runtime/rejoin.zig b/apps/d2gs/runtime/rejoin.zig index deb6b8c0..9178426f 100644 --- a/apps/d2gs/runtime/rejoin.zig +++ b/apps/d2gs/runtime/rejoin.zig @@ -10,7 +10,7 @@ const std = @import("std"); const patch = @import("patch.zig"); -const joinctx = @import("../realmclient/joinctx.zig"); +const joinctx = @import("gs_seats"); const log = @import("../log.zig"); extern "kernel32" fn EnterCriticalSection(cs: usize) callconv(.winapi) void; diff --git a/apps/d2host/main.zig b/apps/d2host/main.zig index cc682365..0c91ce3a 100644 --- a/apps/d2host/main.zig +++ b/apps/d2host/main.zig @@ -19,10 +19,14 @@ const hostapi = @import("d2engine").hostapi; const gameflags = @import("d2engine").gameflags; const d2version = @import("d2engine").version; const charrecord = @import("d2engine").charrecord; +const charsave = @import("d2engine").charsave; +const saveinterval = @import("d2engine").saveinterval; +const seats = @import("gs_seats"); const build_options = @import("build_options"); const health = @import("health.zig"); const HMODULE = *anyopaque; +extern "kernel32" fn GetSystemTimeAsFileTime(lpSystemTimeAsFileTime: *[2]u32) callconv(.winapi) void; extern "kernel32" fn LoadLibraryA(name: [*:0]const u8) callconv(.winapi) ?HMODULE; extern "kernel32" fn ExitProcess(code: u32) callconv(.winapi) noreturn; extern "kernel32" fn InitializeCriticalSection(cs: *anyopaque) callconv(.winapi) void; @@ -37,6 +41,7 @@ extern "kernel32" fn WriteFile(h: *anyopaque, buf: [*]const u8, n: u32, wrote: * extern "kernel32" fn AllocConsole() callconv(.winapi) i32; extern "kernel32" fn AddVectoredExceptionHandler(first: u32, handler: *const fn (*ExceptionPointers) callconv(.winapi) i32) callconv(.winapi) ?*anyopaque; extern "kernel32" fn GetCommandLineA() callconv(.winapi) [*:0]const u8; +extern "kernel32" fn VirtualProtect(addr: usize, size: usize, protect: u32, old: *u32) callconv(.winapi) i32; var out_handle: ?*anyopaque = null; @@ -184,8 +189,12 @@ const Pending = struct { /// token the engine carries with the player and hands back in SaveDatabaseCharacter. pvpgn's real /// 1.09 server sets it to these two constants, and matching a working host costs nothing where /// guessing might cost an afternoon. +// The two dwords of a FILETIME, passed BY POINTER as the character save's timestamp. The engine +// copies both into pClient->pFileTime and CompareFileTime's them against what fpGetDatabaseFileTime +// reports, to refuse a ladder character whose save looks staler than the realm's copy. It is a +// timestamp, not a pointer to one — handing it an address made every ladder join fail. Filled with +// "now" per delivery: for a single-authority store the save we just handed over IS the newest. var load_filetime: [2]u32 = .{ 0, 0 }; -var load_filetimes: [2]u32 = undefined; /// What pvpgn's d2gs109 puts in PLAYERINFO before every character it sends. const player_info_pre110: [2]u32 = .{ 0xabcdef, 0xfedcba }; @@ -264,37 +273,11 @@ fn Binding(comptime version: d2version.Version) type { for (0..32) |i| txt[i] = if (p[i] >= 0x20 and p[i] < 0x7f) p[i] else '.'; sayFmt(" [edx] = {s}", .{txt}); } - var found_any = false; - for (&join_contexts) |*j| { - if (!j.used) continue; - const names = [_][]const u8{ j.charName(), std.mem.sliceTo(&j.account, 0) }; - const labels = [_][]const u8{ "szCharName", "szAccName" }; - for (names, labels) |needle, label| { - if (needle.len == 0) continue; - const span: usize = 0x600; - const anchors = [_]struct { name: []const u8, at: usize }{ - .{ .name = "ecx", .at = ecx }, - .{ .name = "edx", .at = edx }, - }; - for (anchors) |anchor| { - if (anchor.at <= span) continue; - const from = anchor.at -% span; - var at: usize = 0; - while (at < span * 2) : (at += 1) { - const p: [*]const u8 = @ptrFromInt(from + at); - if (!std.mem.eql(u8, p[0..needle.len], needle)) continue; - // A field, not a stray copy: it should be NUL-terminated in place. - if (p[needle.len] != 0) continue; - const delta = @as(isize, @intCast(from + at)) - @as(isize, @intCast(anchor.at)); - sayFmt(" found {s} \"{s}\" at {s}{d} (0x{x} away)", .{ - label, needle, anchor.name, delta, @abs(delta), - }); - found_any = true; - } - } - } - } - if (!found_any) say(" neither name found within +/-0x600 of ecx — widen the search"); + probe_found_any = false; + probe_ecx = ecx; + probe_edx = edx; + seats.forEachRemembered({}, probeForNames); + if (!probe_found_any) say(" neither name found within +/-0x600 of ecx — widen the search"); say("d2host: probe only — refusing the join until the offsets are recorded"); return 0; } @@ -305,7 +288,8 @@ fn Binding(comptime version: d2version.Version) type { pub fn getDatabaseCharacterEdx(ecx: usize, edx: usize, client_id: usize) callconv(.c) usize { _ = ecx; const char_name = std.mem.sliceTo(@as([*:0]const u8, @ptrFromInt(edx)), 0); - const acct_name = accountFor(char_name) orelse ""; + var ab: [seats.max_account]u8 = undefined; + const acct_name = accountFor(char_name, &ab) orelse ""; if (acct_name.len == 0) sayFmt("d2host: no account known for '{s}' — the realm sent no JOINGAME for it", .{char_name}); const slot = for (&pending) |*p| { @@ -315,7 +299,9 @@ fn Binding(comptime version: d2version.Version) type { return 0; }; slot.* = .{ .used = true, .client_id = @intCast(client_id), .container = 0 }; - slot.len = @intCast(store.getChar(acct_name, char_name, &slot.save)); + const loaded = store.getCharVersioned(acct_name, char_name, &slot.save); + slot.len = @intCast(loaded.len); + if (loaded.len > 0) seats.setVersion(char_name, loaded.ver); sayFmt("d2host: fpGetDatabaseCharacter ({s}) — save bytes 0x{x}", .{ char_name, slot.len }); return 0; } @@ -327,7 +313,8 @@ fn Binding(comptime version: d2version.Version) type { const char_name = std.mem.sliceTo(sz_char, 0); // The realm's JOINGAME is the only place the account is known; the engine leaves its // own field empty on this path, so fall back to it only if we were never told. - const acct_name = accountFor(char_name) orelse std.mem.sliceTo(sz_acct, 0); + var ab: [seats.max_account]u8 = undefined; + const acct_name = accountFor(char_name, &ab) orelse std.mem.sliceTo(sz_acct, 0); if (acct_name.len == 0) { // Nothing told us the account: the engine leaves its field empty on this path and // no JOINGAME for this character reached us. Say so, because the alternative is a @@ -346,7 +333,9 @@ fn Binding(comptime version: d2version.Version) type { // save comes back and removes the client if it disagrees. const container_slot: *const usize = @ptrFromInt(ecx -% 8); slot.* = .{ .used = true, .client_id = @intCast(client_id), .container = container_slot.* }; - slot.len = @intCast(store.getChar(acct_name, char_name, &slot.save)); + const loaded = store.getCharVersioned(acct_name, char_name, &slot.save); + slot.len = @intCast(loaded.len); + if (loaded.len > 0) seats.setVersion(char_name, loaded.ver); sayHex("d2host: fpGetDatabaseCharacter — save bytes ", slot.len); return 0; } @@ -393,7 +382,8 @@ fn Binding(comptime version: d2version.Version) type { }); if (!realmConfigured()) return 1; const want = std.mem.sliceTo(char_name, 0); - const staged = accountFor(want) orelse { + var sb: [seats.max_account]u8 = undefined; + const staged = accountFor(want, &sb) orelse { sayFmt("d2host: REFUSED join — the realm staged no context for '{s}'", .{want}); return 0; }; @@ -454,7 +444,14 @@ fn Binding(comptime version: d2version.Version) type { cb.stackArgs(spec.stack_args, .fpGetDatabaseCharacter), getDatabaseCharacter, ).shim), - .save_database_character = stubFor(.fpSaveDatabaseCharacter, "pfSaveDatabaseCharacter"), + // The slot that makes progress survive the game. It was a reporting stub, which + // meant every pre-1.14 engine we host played perfectly and persisted nothing: + // a player's character came back from whatever the realm last wrote, which is + // character creation. Nothing logged a failure, because nothing failed. + .save_database_character = @ptrCast(&fastcall.Callback2( + cb.stackArgs(spec.stack_args, .fpSaveDatabaseCharacter), + saveDatabaseCharacter, + ).shim), .server_log_message = @ptrCast(&serverLogMessage), .enter_game = stubFor(.fpEnterGame, "pfEnterGame"), .find_player_token = @ptrCast(&fastcall.Callback2( @@ -526,7 +523,8 @@ fn pumpCharacterLoads() void { // No container argument before 1.10: the engine had not started cross-checking it. _ = send(p.client_id, &p.save, size, size, lock, 0, &player_info_pre110); } else if (send_character) |send| { - _ = send(p.client_id, &p.save, size, size, lock, 0, &load_filetimes, p.container); + GetSystemTimeAsFileTime(&load_filetime); + _ = send(p.client_id, &p.save, size, size, lock, 0, &load_filetime, p.container); } else return; if (!refused) { sayHex("d2host: character delivered, bytes ", p.len); @@ -612,7 +610,7 @@ fn handleCreateGame(seq: u32, body: []const u8) void { const is_expansion = body[1] != 0; const difficulty: u3 = @truncate(body[2]); const is_hardcore = body[3] != 0; - const flags = gameflags.gameFlags(difficulty, is_expansion, is_hardcore); + const flags = gameflags.gameFlags(difficulty, is_expansion, is_hardcore, ladder != 0); var off: usize = 4; const want_name = proto.readCStr(body, &off); @@ -638,47 +636,52 @@ fn handleCreateGame(seq: u32, body: []const u8) void { _ = store.putReply(seq, std.mem.asBytes(&reply), 30); } -/// Who is joining, remembered from the realm's JOINGAME so the character fetch can find them. +/// Who is joining, remembered from the realm's JOINGAME so the character fetch and every later +/// save can find them. /// -/// The engine's join path carries the character name and the token but **never the account** — it -/// leaves `pClient+0x1D` empty — and the save is keyed by account. Without this the fetch looks up -/// `realmd:char::` and misses, which surfaces as a refused join with nothing to explain it. -const JoinContext = struct { - used: bool = false, - char: [24]u8 = @splat(0), - account: [24]u8 = @splat(0), - - fn charName(self: *const JoinContext) []const u8 { - return std.mem.sliceTo(&self.char, 0); - } -}; - -var join_contexts: [16]JoinContext = @splat(.{}); - -fn rememberJoin(char: []const u8, account: []const u8) void { - if (char.len == 0 or account.len == 0) { - sayFmt("d2host: JOINGAME with no char/account to cache ('{s}'/'{s}')", .{ char, account }); - return; +/// The table itself is `packages/gs-seats`, shared with both other game servers. It used to be a +/// local array here, and the 1.14d DLL had its own, and the Mac server had none — three answers to +/// one question, which is three chances for a save to be filed under the wrong account. It also +/// carries the store VERSION each character was loaded at, which is what every save is fenced +/// against. +/// Scratch for `probeClientFields`, which walks the seat table through a callback and so cannot +/// close over its own locals. Probe-only, single-threaded, and never read outside that path. +var probe_ecx: usize = 0; +var probe_edx: usize = 0; +var probe_found_any = false; + +/// Search memory around ECX/EDX for a name the realm told us about, and report the distance. That +/// distance is the `hostapi.clientFields` measurement, taken from the only place the layout is +/// actually visible. +fn probeForNames(_: void, charname: []const u8, account: []const u8) void { + const names = [_][]const u8{ charname, account }; + const labels = [_][]const u8{ "szCharName", "szAccName" }; + for (names, labels) |needle, label| { + if (needle.len == 0) continue; + const span: usize = 0x600; + const anchors = [_]struct { name: []const u8, at: usize }{ + .{ .name = "ecx", .at = probe_ecx }, + .{ .name = "edx", .at = probe_edx }, + }; + for (anchors) |a| { + if (a.at <= span) continue; + const from = a.at -% span; + var at: usize = 0; + while (at < span * 2) : (at += 1) { + const p: [*]const u8 = @ptrFromInt(from + at); + if (!std.mem.eql(u8, p[0..needle.len], needle)) continue; + // A field, not a stray copy: it should be NUL-terminated in place. + if (p[needle.len] != 0) continue; + const delta = @as(isize, @intCast(from + at)) - @as(isize, @intCast(a.at)); + sayFmt(" found {s} \"{s}\" at {s}{d} (0x{x} away)", .{ label, needle, a.name, delta, @abs(delta) }); + probe_found_any = true; + } + } } - // Newest wins: a re-join of the same character replaces its entry rather than filling the - // table with stale copies. - const slot = for (&join_contexts) |*j| { - if (j.used and std.mem.eql(u8, j.charName(), char)) break j; - } else for (&join_contexts) |*j| { - if (!j.used) break j; - } else &join_contexts[0]; - - slot.* = .{ .used = true }; - @memcpy(slot.char[0..@min(char.len, 23)], char[0..@min(char.len, 23)]); - @memcpy(slot.account[0..@min(account.len, 23)], account[0..@min(account.len, 23)]); - sayFmt("d2host: JOINGAME cached {s}/{s} for the character fetch", .{ account, char }); } -fn accountFor(char: []const u8) ?[]const u8 { - for (&join_contexts) |*j| { - if (j.used and std.mem.eql(u8, j.charName(), char)) return std.mem.sliceTo(&j.account, 0); - } - return null; +fn accountFor(char: []const u8, out: []u8) ?[]const u8 { + return seats.accountForChar(char, out); } /// `JOINGAMEREQ: gameid, token, charname\0, account\0`. The realm has already authorised this @@ -696,7 +699,16 @@ fn handleJoinGame(seq: u32, body: []const u8) void { var off: usize = 8; const charname = proto.readCStr(body, &off); const account = proto.readCStr(body, &off); - rememberJoin(charname, account); + // Refused rather than served if the seat cannot be tracked: a join admitted without an + // account is a session whose saves are silently lost, and a name already seated for a + // DIFFERENT account is one whose saves would land under the wrong one. + if (charname.len == 0 or account.len == 0 or !seats.remember(0, gameid, charname, account, "")) { + sayFmt("d2host: REFUSING join — cannot track '{s}'/'{s}' (name seated elsewhere, or unusable)", .{ account, charname }); + reply.result = 1; + _ = store.putReply(seq, std.mem.asBytes(&reply), 30); + return; + } + sayFmt("d2host: JOINGAME cached {s}/{s} for the character fetch", .{ account, charname }); health.players_joined +%= 1; reply.gameid = gameid; _ = store.putReply(seq, std.mem.asBytes(&reply), 30); @@ -940,6 +952,74 @@ const event_ttl_s: u32 = 3600; /// every character in it stayed claimed. That surfaces three moves later and reads as three /// separate bugs: `create game '' -> name already exists`, `character '' is held by /// game:N`, and a join refused 0x2b "game is full" with nobody in it. +/// `fpSaveDatabaseCharacter` (slot 0x0C) — persist a character mid-game and on the way out. +/// +/// `__fastcall (LPGAMEDATA, char *szCharName, char *szAccountName, void *pSave, u32 nSize, +/// PLAYERDATA)`, four stack arguments on every version we host. That signature is pvpgn's own +/// 1.09d game server header verbatim (`d2gelib/d2server.h`), and it matches what 1.14d's call site +/// @0x53220d builds — the shape is shared across the whole family, which is why one handler serves +/// every engine here. +/// +/// `pSave` is a `u16` length followed by the .d2s; `packages/d2engine/charsave.zig` owns that +/// decode so this host and the 1.14d one cannot drift on the two-byte offset. +fn saveDatabaseCharacter(ecx: usize, edx: usize, account: usize, save: usize, size: usize, player: usize) callconv(.c) usize { + _ = .{ ecx, player }; + + if (save == 0 or size < charsave.min_blob or size > charsave.max_d2s + 2) { + sayFmt("d2host: pfSaveDatabaseCharacter — implausible save size {d}, dropped", .{size}); + return 0; + } + const blob: [*]const u8 = @ptrFromInt(save); + const decoded = charsave.decode(blob[0..size]) catch |e| { + sayFmt("d2host: pfSaveDatabaseCharacter — not a save ({s}), dropped", .{@errorName(e)}); + return 0; + }; + + // The account is what the save is keyed by, and there is no honest guess: a save filed under + // the wrong account is a well-formed save at an address no login path reads, so the player is + // silently rolled back. Prefer the engine's own argument, fall back to the realm's JOINGAME + // context, and drop it loudly rather than invent one. + const from_engine = if (account != 0) std.mem.sliceTo(@as([*:0]const u8, @ptrFromInt(account)), 0) else ""; + var acct_fallback: [seats.max_account]u8 = undefined; + const acct = if (store.keyable(from_engine) and !eqlIgnoreCase(from_engine, decoded.charname)) + from_engine + else + accountFor(decoded.charname, &acct_fallback) orelse { + sayFmt("d2host: pfSaveDatabaseCharacter — no account known for '{s}', save DROPPED", .{decoded.charname}); + return 0; + }; + + // Fenced against the version this character was loaded at: the store accepts these bytes only + // if nothing has written the character since. That is what makes a rollback impossible here + // rather than merely unlikely. + switch (store.putCharDurable(acct, decoded.charname, decoded.d2s, seats.version(decoded.charname))) { + .stored => |ver| { + seats.setVersion(decoded.charname, ver); + sayFmt("d2host: saved {s}/{s} ({d} bytes, v{d})", .{ acct, decoded.charname, decoded.d2s.len, ver }); + }, + .stale => |cur| sayFmt( + "d2host: save REFUSED as stale for {s}/{s} — store is at v{d}, ours was v{d}; NOT overwriting", + .{ acct, decoded.charname, cur, seats.version(decoded.charname) }, + ), + // Deferred, not lost: the bytes are queued with their fence and the service loop retries + // them. Said plainly anyway, because a queue that is never empty needs looking at. + .unavailable => sayFmt( + "d2host: save DEFERRED for {s}/{s} — store did not answer, {d} waiting", + .{ acct, decoded.charname, store.pending() }, + ), + } + _ = edx; // szCharName; the save's own header names it, and that is what we file it under + return 1; +} + +fn eqlIgnoreCase(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + for (a, b) |x, y| { + if (std.ascii.toLower(x) != std.ascii.toLower(y)) return false; + } + return true; +} + fn closeGame(ecx: usize, edx: usize) callconv(.c) usize { _ = edx; const gid: u32 = @truncate(ecx); @@ -1171,6 +1251,76 @@ fn noteModule(name: []const u8, base: usize) void { } loaded_modules[loaded_module_count] = .{ .name = name, .base = base, .end = end }; loaded_module_count += 1; + // D2Game owns the server game loop, and with it the save interval. Done here rather than at a + // fixed address because the address is different in every engine this host serves. + if (std.mem.eql(u8, name, "D2Game.dll")) shortenSaveInterval(name, base, end); +} + +/// Shorten how often the engine saves a character mid-game. +/// +/// Every engine here autosaves on a game-frame interval — 8192 frames, about five and a half +/// minutes — and otherwise only on a clean leave. A game shorter than that never autosaves at all, +/// and a server lost mid-session hands the player back where they were minutes ago. Neither is a +/// rollback in the sense of old bytes overwriting new; both are simply progress that was never +/// written down, which the player experiences identically. +/// +/// The site is FOUND rather than measured, because there are five engines here and the constant +/// identifies itself: see `saveinterval`. Exactly one match is required. Zero means this engine +/// does something else; more than one means the constant is not the identity we assumed. Both +/// leave the stock interval in place — a longer save interval is a known cost, and a byte written +/// into the middle of the wrong instruction is not. +fn shortenSaveInterval(module: []const u8, base: usize, end: usize) void { + if (end <= base) return; + const code: []const u8 = @as([*]const u8, @ptrFromInt(base))[0 .. end - base]; + + var sites: [4]saveinterval.Site = undefined; + const n = saveinterval.findMaskSites(code, saveinterval.stock_frames, &sites); + if (n != 1) { + sayFmt("d2host: autosave interval NOT shortened in {s} — {d} candidate sites, want exactly 1", .{ module, n }); + return; + } + + const frames = saveinterval.round(autosave_frames); + var mask_bytes: [4]u8 = undefined; + std.mem.writeInt(u32, &mask_bytes, saveinterval.mask(frames), .little); + var fixup_bytes: [4]u8 = undefined; + std.mem.writeInt(u32, &fixup_bytes, saveinterval.fixup(frames), .little); + const at = base + sites[0].imm_at; + // Read as bytes. An instruction's immediate is wherever the encoding puts it, which is almost + // never 4-byte aligned, and a typed load of it panics on "incorrect alignment" — which is + // exactly what this did the first time it was pointed at a real D2Game.dll. + const before = std.mem.readInt(u32, @as([*]const u8, @ptrFromInt(at))[0..4], .little); + if (before != saveinterval.mask(saveinterval.stock_frames)) { + sayFmt("d2host: autosave immediate moved under us in {s}; leaving it alone", .{module}); + return; + } + if (!writeProtected(at, &mask_bytes)) { + sayFmt("d2host: could not write the autosave interval in {s}", .{module}); + return; + } + // The negative-frame fixup has to agree with the mask or the two contradict each other. It is + // an unreachable path — the frame counter would have to run for years — so a miss here is + // reported and not fatal. + if (saveinterval.findFixupAfter(code, sites[0].at + sites[0].len, saveinterval.stock_frames, 32)) |fx| { + _ = writeProtected(base + fx.imm_at, &fixup_bytes); + } + sayFmt("d2host: {s} autosaves every {d} frames (~{d}s), was {d}", .{ + module, frames, frames / saveinterval.fps, saveinterval.stock_frames, + }); +} + +/// How often a character is saved mid-game, in engine frames. Overridable because the right value +/// is a trade between write load and how much play a crash costs, and that differs per realm. +var autosave_frames: u32 = 512; + +/// Write bytes into a module's code, making the page writable for the duration. +fn writeProtected(at: usize, bytes: []const u8) bool { + var old: u32 = 0; + if (VirtualProtect(at, bytes.len, 0x40, &old) == 0) return false; // PAGE_EXECUTE_READWRITE + @memcpy(@as([*]u8, @ptrFromInt(at))[0..bytes.len], bytes); + var back: u32 = 0; + _ = VirtualProtect(at, bytes.len, old, &back); + return true; } /// Which module `addr` is in, or null when it is not in any of them. @@ -1739,7 +1889,6 @@ fn createGame(comptime version: d2version.Version, d2game: HMODULE) !void { // @10007 — the async half of fpGetDatabaseCharacter. Without it a fetched save has nowhere to // go and every join stalls, so say so at startup rather than at the first join. if (byOrdinal(d2game, hostapi.sendDatabaseCharacter(version).?.ordinal)) |p| { - load_filetimes = .{ @truncate(@intFromPtr(&load_filetime)), 0 }; if (comptime hostapi.sendDatabaseCharacterArgs(version) orelse 8 == 7) { send_character7 = @ptrCast(@alignCast(p)); say("d2host: D2GSSendDatabaseCharacter @10007 resolved (7-argument form)"); @@ -1757,7 +1906,7 @@ fn createGame(comptime version: d2version.Version, d2game: HMODULE) !void { @memcpy(name[0..5], "spike"); var game_id: u16 = 0; say("d2host: no realm configured — creating one game directly"); - ok = create_game.?(&name, "", "d2host spike", gameflags.gameFlags(0, true, false), 0, 0, 8, &game_id); + ok = create_game.?(&name, "", "d2host spike", gameflags.gameFlags(0, true, false, false), 0, 0, 8, &game_id); sayHex("d2host: GAME_CreateNewEmptyGame returned=", @intCast(ok)); sayHex("d2host: esp after CreateNewEmptyGame = ", espNow()); sayHex("d2host: gameId=", game_id); @@ -1799,6 +1948,12 @@ fn createGame(comptime version: d2version.Version, d2game: HMODULE) !void { const worker_ctx_fn = byOrdinal(d2game, game_ord.worker_context); const process_game = byOrdinal(d2game, game_ord.process_game); const flush_game = byOrdinal(d2game, game_ord.flush_game); + // The cooperative model, where this version has been measured against a real server. It + // replaces the per-game worker loop below rather than joining it: `process_all_games` walks + // the game array itself, so iterating games here as well would tick each one twice. + const process_all = if (comptime game_ord.process_all_games) |o| byOrdinal(d2game, o) else null; + const dispatch_cleanup = if (comptime game_ord.dispatch_cleanup) |o| byOrdinal(d2game, o) else null; + if (process_all != null) say("d2host: driving the engine cooperatively (process-all-games)"); var worker_ctx: usize = 0; if (worker_ctx_fn) |p| { @@ -1866,7 +2021,26 @@ fn createGame(comptime version: d2version.Version, d2game: HMODULE) !void { // Drain every game the worker has ready, not just one per frame: at 50 ms a frame a // single game per tick is a hard cap on how fast anything reaches a client. - if (process_game) |proc| { + if (process_all) |all| { + // `SrvProcessAllGames(0)` returns whether any game ticked; the outbound dispatch runs + // only when one did, exactly as the reference server does it. Stack drift is measured + // because a wrong arity here is silent until something unrelated returns into it. + const esp_pre = espNow(); + const ticked = @as(*const fn (u32) callconv(.winapi) u32, @ptrCast(@alignCast(all)))(0); + if (ticked != 0) { + flushes += 1; + health.game_frames = @truncate(flushes); + if (flushes % 500 == 1) sayFmt("d2host: processed {d} game frame(s)", .{flushes}); + if (dispatch_cleanup) |disp| { + _ = @as(*const fn (u32, u32) callconv(.winapi) u32, @ptrCast(@alignCast(disp)))(0, 0); + } + } + const esp_post = espNow(); + if (esp_post != esp_pre and stack_drift_reports < 8) { + stack_drift_reports += 1; + sayFmt("d2host: STACK DRIFT across process-all-games: esp 0x{x} -> 0x{x}", .{ esp_pre, esp_post }); + } + } else if (process_game) |proc| { var spins: usize = 0; while (spins < 64) : (spins += 1) { // One word is enough: given 128 bytes of room and 1.06b driving it, the engine @@ -1898,6 +2072,9 @@ fn createGame(comptime version: d2version.Version, d2game: HMODULE) !void { } } } + // Hand back any save the store refused earlier. A no-op on a healthy server; on an + // unhealthy one it is what keeps a blip from costing somebody their session. + _ = store.retryPending(); // 10 ms is the idle cadence a third-party host publishes as DEFAULT_IDLE_SLEEP, and // Blizzard's own worker loop is tighter still — it spins on a network wait rather than // sleeping. At 50 ms the engine's scheduler rarely had a game ready when we asked. diff --git a/apps/realmd/admin.zig b/apps/realmd/admin.zig index eed2eb97..2c2be96e 100644 --- a/apps/realmd/admin.zig +++ b/apps/realmd/admin.zig @@ -532,7 +532,13 @@ fn charsDelete(fd: net.Socket, req: []const u8) void { const body = bodyOf(req); const account = jsonStr(body, "account") orelse return respond(fd, bad_request, "{\"error\":\"missing account\"}"); const char = jsonStr(body, "char") orelse return respond(fd, bad_request, "{\"error\":\"missing char\"}"); + // Same rule as the client's own delete: a character in a game is held in memory by the game + // server and written back on its next save, so deleting it here loses a race rather than + // ending a session — and if the name is reused before that save lands, the old session's bytes + // arrive on top of a brand-new character. An operator gets told to wait, not a silent no-op. + if (store.charInUse(account, char)) return respond(fd, conflict, "{\"error\":\"character is in a game\"}"); if (!store.deleteCharD2s(account, char)) return respond(fd, conflict, "{\"error\":\"delete failed\"}"); + store.releaseCharName(account, char); respond(fd, ok, "{\"deleted\":true}"); } diff --git a/apps/realmd/d2cs.zig b/apps/realmd/d2cs.zig index 4c5563b2..3913c575 100644 --- a/apps/realmd/d2cs.zig +++ b/apps/realmd/d2cs.zig @@ -549,10 +549,26 @@ fn onCharCreate(c: *DConn, tag: []const u8, body: []const u8) void { w.putU32(0x14); return finish(c, &w); } + // Claimed across the whole realm, not just this account, and claimed ATOMICALLY — which also + // settles the race the probe above cannot: two creates of the same name, on two instances, can + // both pass a check-then-act and the second silently replaces the first. + // + // Realm-wide because a character name is an identity everywhere downstream of here: the game + // servers' seat tables, the save-retry queue, a departing player's seat release, and the Mac + // engine's own `.d2s` all key on it. Two characters sharing a name is one player's + // save landing under the other's account. + if (!store.claimCharName(acct, name)) { + log.line(tag, "char create '{s}' (account={s}) -> name taken realm-wide", .{ name, acct }); + w.putU32(0x14); + return finish(c, &w); + } var save: [d2s.new_save_size]u8 = undefined; const now: u32 = @truncate(@as(u64, @bitCast(@as(i64, time(null))))); // Honor the client's flags as-is (classic = no 0x20, expansion = 0x20, +hardcore/ladder). if (!d2s.newSave(&save, name, class, status_flags, now) or !store.saveCharD2s(acct, name, &save)) { + // The claim outlives the failure otherwise, and the name is then held by a character that + // does not exist — unusable by anyone, including the player who just tried. + store.releaseCharName(acct, name); log.line(tag, "char create '{s}' (account={s}) -> store FAILED", .{ name, acct }); w.putU32(0x06); return finish(c, &w); @@ -576,7 +592,12 @@ fn onCharCreate(c: *DConn, tag: []const u8, body: []const u8) void { fn charStatus(c: *DConn) u8 { var save: [64]u8 = undefined; const n = store.getCharD2s(c.accountName(), c.charName(), &save); - if (n <= 0x24) return STATUS_EXPANSION; + if (n <= 0x24) { + // Say so. This decides whether the game is classic/hardcore/ladder, and guessing wrong + // makes the engine refuse the creator's own join for a reason the player never sees. + log.line("d2cs", "char status unreadable for '{s}' ({d} bytes) -> assuming expansion softcore non-ladder", .{ c.charName(), n }); + return STATUS_EXPANSION; + } return save[0x24] & STATUS_JOIN_MASK; } @@ -960,7 +981,22 @@ fn onCharDelete(c: *DConn, tag: []const u8, body: []const u8) void { var r = proto.Reader.init(body); const reqid = r.getU16(); const name = r.getStr(); + // Refused while the character is in a game. The game server holds it in memory and saves it + // back on its own timer, so deleting it here does not end the session — it just loses the + // race: the next save recreates the character, and if the player has meanwhile made a NEW one + // with the same name, that stale save lands on top of it. A delete that cannot be made to + // stick is better refused than half-done. + if (store.charInUse(c.accountName(), name)) { + log.line(tag, "char delete '{s}' (account={s}) -> REFUSED, still in a game", .{ name, c.accountName() }); + var busy: [16]u8 = undefined; + var bw = startPacket(&busy, MCP_CHARDELETE); + bw.putU16(reqid); + bw.putU32(1); + return finish(c, &bw); + } const ok = store.deleteCharD2s(c.accountName(), name); + // The name goes back to the realm with the character. + if (ok) store.releaseCharName(c.accountName(), name); log.line(tag, "char delete '{s}' (account={s}) -> {s}", .{ name, c.accountName(), if (ok) "deleted" else "FAILED" }); var buf: [16]u8 = undefined; var w = startPacket(&buf, MCP_CHARDELETE); diff --git a/apps/realmd/fleet.zig b/apps/realmd/fleet.zig index 941a784a..2091a475 100644 --- a/apps/realmd/fleet.zig +++ b/apps/realmd/fleet.zig @@ -323,9 +323,23 @@ fn apply(typ: p.Type, body: []const u8) void { // count alone only fills the PLAYERS column. state.global.setGameMember(gameid, flag != p.GAMEINFO_LEAVE, char, @intCast(@min(level, 255)), @intCast(@min(class, 255))); // Freed as the player leaves rather than when the game ends, so a character is - // available for its next game immediately. Matched by name because a departure - // carries no account, which is why the realm keeps the pairing itself. - if (flag == p.GAMEINFO_LEAVE and char.len > 0) _ = store.releaseGameCharByName(gameid, char); + // available for its next game immediately. + // + // With the account, the seat is named exactly. Without it — an older game server, or + // one that had lost the pairing — the fallback matches by character name, and that is + // only safe while the name is unambiguous inside the game: names are unique per + // account on this realm, so two "Bob"s in one game means guessing, and guessing frees + // a lock belonging to a player who is still in the world. `releaseGameCharByName` + // therefore releases nothing when it cannot tell them apart, and the seat waits for + // the game-close sweep, which needs no names at all. + const acct = if (off < body.len) p.readCStr(body, &off) else ""; + if (flag == p.GAMEINFO_LEAVE and char.len > 0) { + if (acct.len > 0) { + _ = store.releaseGameCharExact(gameid, acct, char); + } else { + _ = store.releaseGameCharByName(gameid, char); + } + } }, .closegame => { if (body.len < 8) return; diff --git a/apps/realmd/store.zig b/apps/realmd/store.zig index fff1e3f5..63745d20 100644 --- a/apps/realmd/store.zig +++ b/apps/realmd/store.zig @@ -267,6 +267,8 @@ pub const max_d2s = 32 * 1024; /// every backend (it goes through get/saveCharD2s). Returns false if the source is missing, /// the name is invalid, the save is implausibly large, or the destination already exists. pub fn copyChar(src_account: []const u8, src_char: []const u8, dst_account: []const u8, dst_char: []const u8) bool { + // The copy brings a new NAME into existence, so it claims one like any other creation. + if (!claimCharName(dst_account, dst_char)) return false; if (dst_char.len == 0 or dst_char.len > d2s.name_max) return false; var buf: [max_d2s]u8 = undefined; const n = getCharD2s(src_account, src_char, &buf); @@ -288,6 +290,7 @@ pub fn copyChar(src_account: []const u8, src_char: []const u8, dst_account: []co /// It will not overwrite. An import that silently replaced a character would be the one operation /// here with no way back. pub fn importChar(account: []const u8, name: []const u8, bytes: []const u8) bool { + if (!claimCharName(account, name)) return false; if (name.len == 0 or name.len > d2s.name_max) return false; if (bytes.len == 0 or bytes.len > max_d2s) return false; if (d2s.status(bytes) == null) return false; // not a save, or too short to be one @@ -590,6 +593,28 @@ pub fn clearDirtyIfUnchanged(account: []const u8, charname: []const u8, ver: u64 /// short enough that a game server lost mid-session frees its characters within a game's length. pub const char_lock_ttl_s: u32 = 300; +/// Claim a character name for `account` across the whole realm, or report that somebody else has +/// it. Every path that brings a NEW character name into existence goes through here — create, +/// copy, import — because a name that is unique only within an account is a name that identifies +/// two different characters, and half the realm identifies a character by name alone. +pub fn claimCharName(account: []const u8, charname: []const u8) bool { + return pg.claimCharName(account, charname); +} + +/// Give a character name back, so somebody else may take it. Called after a delete. +pub fn releaseCharName(account: []const u8, charname: []const u8) void { + pg.releaseCharName(account, charname); +} + +/// Is this character in a game right now? Deleting, copying onto, or importing over one that is +/// costs the player whatever they are doing: the game server holds it in memory and will write it +/// back on its next save, which either resurrects what was deleted or — if the name was reused in +/// between — lands an old session's bytes on a brand-new character. +pub fn charInUse(account: []const u8, charname: []const u8) bool { + var buf: [64]u8 = undefined; + return charLockOwner(account, charname, &buf) != null; +} + pub fn lockChar(account: []const u8, charname: []const u8, owner: []const u8) bool { return redis.lockChar(account, charname, owner, char_lock_ttl_s); } @@ -645,6 +670,13 @@ pub fn releaseGameChars(gameid: u32) usize { return redis.releaseGameChars(gameid, owner); } +/// Release the seat this game holds for exactly this character. Preferred over the by-name form +/// wherever the game server told us the account — it cannot pick the wrong player. +pub fn releaseGameCharExact(gameid: u32, account: []const u8, charname: []const u8) bool { + var buf: [32]u8 = undefined; + return redis.releaseGameCharExact(gameid, account, charname, gameOwnerId(&buf, gameid)); +} + pub fn releaseGameCharByName(gameid: u32, charname: []const u8) bool { var ob: [32]u8 = undefined; const owner = gameOwnerId(&ob, gameid); diff --git a/build.zig b/build.zig index 6204e397..6f77cef4 100644 --- a/build.zig +++ b/build.zig @@ -65,6 +65,13 @@ pub fn build(b: *std.Build) void { }); d2engine.addImport("fastcall", fastcall_mod); + // Which account owns a character seated here. Shared by both game servers: the 1.14d DLL and + // the Mac native host have the same gap (the engine never carries an account) and the same + // consequence for getting it wrong, so they share one table and one set of tests. + const gs_seats = b.addModule("gs_seats", .{ + .root_source_file = b.path("packages/gs-seats/gs_seats.zig"), + }); + // The game server's side of the shared store: the ops a GS needs of the realm — fetch and // save a character, advertise itself, take create/join requests, report events. Domain ops on // the outside, redis on the inside. It lives here rather than inside apps/d2gs because a @@ -148,6 +155,7 @@ pub fn build(b: *std.Build) void { d2gs.root_module.addImport("realm_proto", realm_proto); d2gs.root_module.addImport("resp", resp); d2gs.root_module.addImport("gs_store", gs_store); + d2gs.root_module.addImport("gs_seats", gs_seats); d2gs.root_module.addImport("gs_health", gs_health); d2gs.root_module.addImport("obs", obs); d2gs.root_module.addImport("d2engine", d2engine); @@ -178,6 +186,7 @@ pub fn build(b: *std.Build) void { d2host.root_module.addOptions("build_options", d2host_options); d2host.root_module.addImport("fastcall", fastcall_mod); d2host.root_module.addImport("gs_store", gs_store); + d2host.root_module.addImport("gs_seats", gs_seats); d2host.root_module.addImport("gs_health", gs_health); d2host.root_module.addImport("d2engine", d2engine); d2host.root_module.addImport("realm_proto", realm_proto); @@ -452,6 +461,13 @@ pub fn build(b: *std.Build) void { // The native host's crash reporter. Rooted here rather than at main.zig because that one // runs the game; this is the part with logic worth asserting. .{ "apps/d2gs-native/crash.zig", false, false }, + // Which saves have already been sent to the store. Pure logic, and the thing standing + // between an idle server and a redundant write per player per 45 seconds — and, in the + // other direction, between a changed character and a save that never leaves the disk. + .{ "apps/d2gs-native/savededupe.zig", false, false }, + // Saves the store refused, kept until it takes them. Pure logic, and the difference + // between a redis blip costing a delay and it costing a player's session. + .{ "packages/gs-store/savequeue.zig", false, false }, // The engine callback contract: its layout asserts are the point, and they fire at // compile time on any target, so they are worth checking here and not only in the DLL. .{ "packages/d2engine/d2engine.zig", false, false }, @@ -461,6 +477,11 @@ pub fn build(b: *std.Build) void { // Does our Fog export everything the engines import? Checked against a committed manifest // rather than the DLLs, so it runs on a machine with no game files. .{ "packages/d2fog/ordinals.zig", false, false }, + // The account/character seat table both game servers key their saves by. Pure logic with + // a swappable clock, and the piece that decides which key a character's save is written + // under — get it wrong and a player is silently rolled back, which is exactly the bug + // these tests are here to keep out. + .{ "packages/gs-seats/gs_seats.zig", false, false }, // Does every engine we claim to serve have the measurements that claim rests on? Reads // deploy/e2e-engines.txt at compile time. A test root of its own on purpose: the claim is // about our confidence, not about anything d2host does at runtime, and folding it into the @@ -516,6 +537,8 @@ pub fn build(b: *std.Build) void { d2gs_native.root_module.addImport("darwin", darwin); d2gs_native.root_module.addImport("realm_proto", realm_proto); d2gs_native.root_module.addImport("gs_health", gs_health); + // The same seat table the 1.14d DLL keys its saves by; see packages/gs-seats. + d2gs_native.root_module.addImport("gs_seats", gs_seats); const native_realm_tests = b.addTest(.{ .root_module = b.createModule(.{ .root_source_file = b.path("apps/d2gs-native/realm.zig"), @@ -526,6 +549,7 @@ pub fn build(b: *std.Build) void { }); native_realm_tests.root_module.addImport("macho", macho); native_realm_tests.root_module.addImport("realm_proto", realm_proto); + native_realm_tests.root_module.addImport("gs_seats", gs_seats); test_step.dependOn(&b.addRunArtifact(native_realm_tests).step); b.step("d2gs-native", "Build the wine-free native game server").dependOn( diff --git a/packages/d2engine/charsave.zig b/packages/d2engine/charsave.zig new file mode 100644 index 00000000..0d9b42b9 --- /dev/null +++ b/packages/d2engine/charsave.zig @@ -0,0 +1,153 @@ +//! The character save the engine hands its host, decoded from the raw callback arguments. +//! +//! `fpSaveDatabaseCharacter` (slot 0x0C) has the same shape on every build we host. 1.14d's call +//! site @0x53220d and pvpgn's 1.09d GS header agree argument for argument: +//! +//! __fastcall (pGameOrRealmId, char *szCharName, char *szAccountName, +//! void *pSave, u32 nSize, u32 nClientContainer) +//! +//! `pSave` is NOT the .d2s. It points at a `u16` byte count followed by the save, and `nSize` is +//! that same count — pvpgn's handler copies `pdata + sizeof(short)` for `dwSize - sizeof(short)` +//! bytes, which is the whole of what it forwards to d2dbs. Getting that two-byte offset wrong +//! stores a save the game will never read back and the mistake is invisible until a player logs +//! in, so the decode lives here once rather than in each host. +//! +//! Everything below is pure: give it the bytes and it tells you whether they are a save and what +//! character they belong to. The hosts do the pointer reading, because only they know their own +//! engine's memory. + +const std = @import("std"); + +/// `0xaa55aa55` — the first dword of every .d2s, on every version. A blob without it is not a +/// save, and storing it would replace a character with rubbish. +pub const signature: u32 = 0xaa55_aa55; + +/// The character's name inside the .d2s: 16 bytes at 0x14, NUL-padded. It is taken from here +/// rather than from the callback's `szCharName` argument on purpose — the save is the thing being +/// written, so the name it carries is the one it should be filed under. +pub const name_offset = 0x14; +pub const name_len = 16; + +/// The smallest blob that can be a save: the two length bytes plus enough .d2s to hold the header +/// through the name field. +pub const min_blob = 2 + name_offset + name_len; + +/// A save the engine considers big enough to be worth refusing rather than storing. Real saves are +/// a few KB; anything past this is a length field we misread, and writing it would put megabytes +/// of unrelated memory in the store under a character's name. +pub const max_d2s = 32 * 1024; + +pub const Error = error{ + /// The blob is too short to contain a save header. + TooShort, + /// The declared length disagrees with the bytes actually available. + LengthMismatch, + /// Missing the 0xaa55aa55 signature — not a .d2s. + BadSignature, + /// Implausibly large; see `max_d2s`. + TooLarge, + /// The name field is empty, or has no terminator inside its 16 bytes. + BadName, +}; + +pub const Save = struct { + /// The character's name, as the save itself spells it. + charname: []const u8, + /// The .d2s, ready to store verbatim. + d2s: []const u8, +}; + +/// How many bytes the engine says are at `pSave`, read from its first two bytes. The host uses +/// this to bound the slice it then hands to `decode`; it is separate because reading engine memory +/// is the host's job, not this file's. +pub fn declaredLen(first_two: [2]u8) usize { + return std.mem.readInt(u16, &first_two, .little); +} + +/// Decode `{u16 total; .d2s}` as the callback's 4th argument points at it. +/// +/// `blob.len` must be exactly the declared total: the caller has already read the length to know +/// how much to slice, and checking it again here is what catches a host that sliced by its own +/// buffer size instead. +pub fn decode(blob: []const u8) Error!Save { + if (blob.len < min_blob) return Error.TooShort; + const declared = std.mem.readInt(u16, blob[0..2], .little); + if (declared != blob.len) return Error.LengthMismatch; + + const d2s = blob[2..]; + if (d2s.len > max_d2s) return Error.TooLarge; + if (std.mem.readInt(u32, d2s[0..4], .little) != signature) return Error.BadSignature; + + const field = d2s[name_offset..][0..name_len]; + const end = std.mem.indexOfScalar(u8, field, 0) orelse return Error.BadName; + if (end == 0) return Error.BadName; + return .{ .charname = field[0..end], .d2s = d2s }; +} + +// ── tests ──────────────────────────────────────────────────────────────────── + +const testing = std.testing; + +fn build(name: []const u8, extra: usize) [256]u8 { + var buf: [256]u8 = @splat(0); + const total: u16 = @intCast(2 + name_offset + name_len + extra); + std.mem.writeInt(u16, buf[0..2], total, .little); + std.mem.writeInt(u32, buf[2..6], signature, .little); + @memcpy(buf[2 + name_offset ..][0..name.len], name); + return buf; +} + +test "a well-formed save decodes to its name and its bytes" { + const buf = build("Persist", 0); + const s = try decode(buf[0..min_blob]); + try testing.expectEqualStrings("Persist", s.charname); + try testing.expectEqual(@as(usize, min_blob - 2), s.d2s.len); + // The .d2s starts AT the signature — the two length bytes are not part of it. A host that + // stored `blob` instead of `s.d2s` would prepend two bytes to every character it saved. + try testing.expectEqual(signature, std.mem.readInt(u32, s.d2s[0..4], .little)); +} + +test "a name filling the whole field has no terminator, and is refused" { + // 16 bytes with no NUL is not a name we can read; guessing the length would silently rename + // the character and file the save somewhere new. + var buf = build("", 0); + @memcpy(buf[2 + name_offset ..][0..name_len], "AAAAAAAAAAAAAAAA"); + try testing.expectError(Error.BadName, decode(buf[0..min_blob])); +} + +test "an empty name is refused" { + const buf = build("", 0); + try testing.expectError(Error.BadName, decode(buf[0..min_blob])); +} + +test "a blob without the signature is not a save" { + var buf = build("Persist", 0); + std.mem.writeInt(u32, buf[2..6], 0xdead_beef, .little); + try testing.expectError(Error.BadSignature, decode(buf[0..min_blob])); +} + +test "a declared length that disagrees with the slice is refused" { + // This is the host-sliced-by-its-own-buffer bug: the blob is real, the slice is not what the + // engine described, and storing it would truncate or over-read the save. + var buf = build("Persist", 0); + std.mem.writeInt(u16, buf[0..2], min_blob + 8, .little); + try testing.expectError(Error.LengthMismatch, decode(buf[0..min_blob])); +} + +test "a blob too short to hold a header is refused before anything is read from it" { + var buf: [8]u8 = @splat(0); + std.mem.writeInt(u16, buf[0..2], 8, .little); + try testing.expectError(Error.TooShort, decode(&buf)); +} + +test "the declared length is read little-endian" { + try testing.expectEqual(@as(usize, 0x1234), declaredLen(.{ 0x34, 0x12 })); +} + +test "a save decodes the same whatever follows the name" { + // Real saves are a few KB of sections after the header; the decode must not care. + const buf = build("Tail", 64); + const s = try decode(buf[0 .. min_blob + 64]); + try testing.expectEqualStrings("Tail", s.charname); + try testing.expectEqual(@as(usize, min_blob + 64 - 2), s.d2s.len); +} diff --git a/packages/d2engine/d2engine.zig b/packages/d2engine/d2engine.zig index 21873db6..97524aeb 100644 --- a/packages/d2engine/d2engine.zig +++ b/packages/d2engine/d2engine.zig @@ -11,6 +11,8 @@ pub const cs_packets = @import("cs_packets.zig"); pub const fogabi = @import("fogabi.zig"); pub const fogrosetta = @import("fogrosetta.zig"); pub const charrecord = @import("charrecord.zig"); +pub const charsave = @import("charsave.zig"); +pub const saveinterval = @import("saveinterval.zig"); // A test artifact rooted here only runs the tests of files it actually analyses. test { @@ -22,4 +24,6 @@ test { _ = fogabi; _ = fogrosetta; _ = charrecord; + _ = charsave; + _ = saveinterval; } diff --git a/packages/d2engine/saveinterval.zig b/packages/d2engine/saveinterval.zig new file mode 100644 index 00000000..1d50120b --- /dev/null +++ b/packages/d2engine/saveinterval.zig @@ -0,0 +1,264 @@ +//! How often the engine saves a character mid-game, expressed the way the engine tests it. +//! +//! `UpdateClients` reaches `SaveAllPlayers` on `dwGameFrame % 8192 == 0`, and the compiler wrote +//! that as the signed-modulo idiom rather than a division: +//! +//! AND EAX, 0x80001fff ; sign bit | (8192 - 1) +//! JNS +7 ; non-negative frames: the AND is already the remainder +//! DEC EAX / OR EAX, 0xffffe000 / INC EAX ; negative frames: sign-extend the remainder +//! JNZ +5 ; zero -> save +//! +//! So the interval is two immediates that have to agree, and shortening it is arithmetic, not new +//! code. The arithmetic lives here — apart from the patching — because it is a fact about the +//! engine and because getting it wrong writes a plausible constant that silently changes the +//! interval to something nobody chose. The tests below re-derive 1.14d's own shipped pair as the +//! proof that the formula matches what the compiler emitted. +//! +//! Only powers of two are expressible: a mask tests a set of low bits, so any other value would +//! fire on a pattern of frames rather than at an interval. + +const std = @import("std"); + +/// The interval the engine ships: 8192 frames, ~5.5 minutes at 25 fps. +pub const stock_frames: u32 = 8192; + +/// Frames per second the server simulates at. Only used to talk about intervals in seconds. +pub const fps: u32 = 25; + +/// The `AND` immediate that tests `frame % frames == 0`. +pub fn mask(frames: u32) u32 { + return 0x8000_0000 | (frames - 1); +} + +/// The `OR` immediate in the negative-frame fixup. It must be the complement of the mask's low +/// bits or the two disagree; `dwGameFrame` would have to run ~994 days at 25 fps to reach that +/// path, but a mask and a fixup that contradict each other is a landmine, not a saving. +pub fn fixup(frames: u32) u32 { + return ~(frames - 1); +} + +pub fn isExpressible(frames: u32) bool { + return frames != 0 and (frames & (frames - 1)) == 0; +} + +/// The largest expressible interval not longer than `frames`, clamped to something a server +/// should actually run at. +/// +/// Rounds DOWN, because the value is a duration to whoever set it and rounding up would quietly +/// give them a longer window than they asked for — the wrong direction for a setting whose whole +/// purpose is bounding how much play a crash can cost. +pub fn round(frames: u32) u32 { + const clamped = @min(@max(frames, min_frames), stock_frames); + // floorPowerOfTwo rather than a hand-rolled clz shift: `31 - @clz(x)` gives the shift the + // smallest type that holds it, which is not u5, and the expression stops compiling — or worse, + // narrows something else the same way somewhere it still does. + return std.math.floorPowerOfTwo(u32, clamped); +} + +/// Below this the save stops being periodic and starts being per-action. +pub const min_frames: u32 = 64; + +// ── finding the instruction in an engine we have not measured ──────────────── +// +// 1.14d's site is known by address. The five pre-1.14 engines are five more binaries, and rather +// than measure each one the site is FOUND: the mask is a 32-bit constant specific enough to +// identify itself. 0x80001fff is the sign bit plus 8191 — the signed-modulo-8192 idiom — and a +// game engine has no other reason to hold that number. +// +// The rule for using this is exactly one match. Zero means the engine does something else and the +// stock interval stands; more than one means the constant is not the identity we assumed it was, +// and guessing which to patch is how a byte lands in the middle of an unrelated instruction. Both +// are refusals, not fallbacks. + +/// Where a mask instruction sits, and where its immediate is inside it. +pub const Site = struct { + /// Offset of the first opcode byte within the scanned slice. + at: usize, + /// Offset of the 32-bit immediate within the scanned slice. + imm_at: usize, + len: usize, +}; + +/// `AND r32, imm32` in the two encodings a compiler emits: `25 id` (EAX only, 5 bytes) and +/// `81 /4 id` (any register, 6 bytes — ModRM 0xE0..0xE7 is register-direct with /4 = AND). +fn matchAnd(code: []const u8, i: usize, imm: u32) ?Site { + if (i + 5 <= code.len and code[i] == 0x25 and std.mem.readInt(u32, code[i + 1 ..][0..4], .little) == imm) { + return .{ .at = i, .imm_at = i + 1, .len = 5 }; + } + if (i + 6 <= code.len and code[i] == 0x81 and code[i + 1] >= 0xE0 and code[i + 1] <= 0xE7 and + std.mem.readInt(u32, code[i + 2 ..][0..4], .little) == imm) + { + return .{ .at = i, .imm_at = i + 2, .len = 6 }; + } + return null; +} + +/// `OR r32, imm32`: `0D id` (EAX, 5 bytes) and `81 /1 id` (ModRM 0xC8..0xCF, 6 bytes). +fn matchOr(code: []const u8, i: usize, imm: u32) ?Site { + if (i + 5 <= code.len and code[i] == 0x0D and std.mem.readInt(u32, code[i + 1 ..][0..4], .little) == imm) { + return .{ .at = i, .imm_at = i + 1, .len = 5 }; + } + if (i + 6 <= code.len and code[i] == 0x81 and code[i + 1] >= 0xC8 and code[i + 1] <= 0xCF and + std.mem.readInt(u32, code[i + 2 ..][0..4], .little) == imm) + { + return .{ .at = i, .imm_at = i + 2, .len = 6 }; + } + return null; +} + +/// Every `AND r32, ` in `code`. Returns how many were written to `out`; a count +/// equal to `out.len` means there may be more, which is itself a reason to refuse. +pub fn findMaskSites(code: []const u8, frames: u32, out: []Site) usize { + var n: usize = 0; + var i: usize = 0; + const imm = mask(frames); + while (i + 5 <= code.len) : (i += 1) { + if (matchAnd(code, i, imm)) |site| { + if (n < out.len) out[n] = site; + n += 1; + if (n >= out.len) break; + } + } + return n; +} + +/// The negative-frame fixup belonging to a mask site: the first `OR r32, ` within +/// `window` bytes after it. Null when there is none, which is not an error — it is an unreachable +/// path either way, and a mask patched without it is still correct for every real frame count. +pub fn findFixupAfter(code: []const u8, from: usize, frames: u32, window: usize) ?Site { + const imm = fixup(frames); + var i = from; + const end = @min(code.len, from + window); + while (i + 5 <= end) : (i += 1) { + if (matchOr(code, i, imm)) |site| return site; + } + return null; +} + +// ── tests ──────────────────────────────────────────────────────────────────── + +const testing = std.testing; + +test "the formula reproduces the pair 1.14d actually ships" { + // 0x80001fff is the imm32 of `AND EAX, 0x80001fff` @0x52d45c and 0xffffe000 the imm32 of + // `OR EAX, 0xffffe000` @0x52d46a. If this ever fails, the formula and the engine disagree and + // the patch would be writing a constant that means something else. + try testing.expectEqual(@as(u32, 0x8000_1fff), mask(stock_frames)); + try testing.expectEqual(@as(u32, 0xffff_e000), fixup(stock_frames)); +} + +test "mask and fixup stay complements at every expressible interval" { + var f: u32 = 1; + while (f <= stock_frames) : (f <<= 1) { + // The low bits the mask keeps are exactly the ones the fixup restores. + try testing.expectEqual(f - 1, mask(f) & 0x7fff_ffff); + try testing.expectEqual(~(mask(f) & 0x7fff_ffff), fixup(f)); + } +} + +test "only powers of two are expressible" { + try testing.expect(isExpressible(1)); + try testing.expect(isExpressible(512)); + try testing.expect(isExpressible(8192)); + try testing.expect(!isExpressible(0)); + try testing.expect(!isExpressible(3)); + try testing.expect(!isExpressible(1000)); +} + +test "rounding never hands back a longer interval than asked for" { + try testing.expectEqual(@as(u32, 512), round(512)); + try testing.expectEqual(@as(u32, 512), round(1000)); + try testing.expectEqual(@as(u32, 4096), round(8191)); + try testing.expectEqual(@as(u32, 8192), round(8192)); + var v: u32 = min_frames; + while (v <= stock_frames) : (v += 1) { + try testing.expect(round(v) <= v); + } +} + +test "rounding is clamped at both ends and always expressible" { + try testing.expectEqual(@as(u32, min_frames), round(0)); + try testing.expectEqual(@as(u32, min_frames), round(1)); + try testing.expectEqual(@as(u32, stock_frames), round(100_000)); + for ([_]u32{ 0, 1, 63, 64, 100, 511, 512, 8191, 8192, 99_999 }) |x| { + try testing.expect(isExpressible(round(x))); + } +} + +test "the interval we default to is well inside the engine's own" { + // ~20s vs ~5.5min. Stated as a relationship rather than a number so a change to either has to + // be deliberate. + const chosen: u32 = 512; + try testing.expect(isExpressible(chosen)); + try testing.expect(chosen < stock_frames); + try testing.expect(chosen / fps <= 30); +} + +test "the mask instruction is found in both encodings" { + // `AND EAX, 0x80001fff` is what 1.14d emits; the ModRM form is what another register would + // give, and a pre-1.14 build is free to use either. + const eax = [_]u8{ 0x90, 0x25, 0xff, 0x1f, 0x00, 0x80, 0x90 }; + var sites: [4]Site = undefined; + try testing.expectEqual(@as(usize, 1), findMaskSites(&eax, stock_frames, &sites)); + try testing.expectEqual(@as(usize, 1), sites[0].at); + try testing.expectEqual(@as(usize, 2), sites[0].imm_at); + try testing.expectEqual(@as(usize, 5), sites[0].len); + + const ecx = [_]u8{ 0x90, 0x81, 0xE1, 0xff, 0x1f, 0x00, 0x80 }; + try testing.expectEqual(@as(usize, 1), findMaskSites(&ecx, stock_frames, &sites)); + try testing.expectEqual(@as(usize, 1), sites[0].at); + try testing.expectEqual(@as(usize, 3), sites[0].imm_at); + try testing.expectEqual(@as(usize, 6), sites[0].len); +} + +test "a different constant is not the instruction we are looking for" { + // The whole method rests on the constant being an identity. A near miss must not match. + const near = [_]u8{ 0x25, 0xff, 0x1f, 0x00, 0x00 }; // 0x00001fff, no sign bit + var sites: [4]Site = undefined; + try testing.expectEqual(@as(usize, 0), findMaskSites(&near, stock_frames, &sites)); +} + +test "an AND that is not register-direct is not matched" { + // 0x81 with a memory ModRM is `AND [mem], imm32` — a different instruction of a different + // length, and patching its immediate would corrupt whatever follows. + const mem = [_]u8{ 0x81, 0x20, 0xff, 0x1f, 0x00, 0x80 }; // ModRM 0x20 = [eax], /4 + var sites: [4]Site = undefined; + try testing.expectEqual(@as(usize, 0), findMaskSites(&mem, stock_frames, &sites)); +} + +test "two matches are reported as two, so a caller can refuse" { + const twice = [_]u8{ + 0x25, 0xff, 0x1f, 0x00, 0x80, + 0x90, 0x90, + 0x25, 0xff, 0x1f, 0x00, 0x80, + }; + var sites: [4]Site = undefined; + try testing.expectEqual(@as(usize, 2), findMaskSites(&twice, stock_frames, &sites)); +} + +test "the fixup is found after its mask, and only within the window" { + const code = [_]u8{ + 0x25, 0xff, 0x1f, 0x00, 0x80, // AND EAX, 0x80001fff + 0x79, 0x07, 0x48, // JNS +7 ; DEC EAX + 0x0D, 0x00, 0xe0, 0xff, 0xff, // OR EAX, 0xffffe000 + }; + const site = findFixupAfter(&code, 5, stock_frames, 32).?; + try testing.expectEqual(@as(usize, 8), site.at); + try testing.expectEqual(@as(usize, 9), site.imm_at); + // Too small a window finds nothing rather than reaching past where it belongs. + try testing.expect(findFixupAfter(&code, 5, stock_frames, 2) == null); +} + +test "scanning a slice with no matches is not an error" { + const noise = [_]u8{0x90} ** 64; + var sites: [4]Site = undefined; + try testing.expectEqual(@as(usize, 0), findMaskSites(&noise, stock_frames, &sites)); + try testing.expect(findFixupAfter(&noise, 0, stock_frames, 64) == null); +} + +test "a scan never runs past the end of the slice" { + // A truncated instruction at the very end must not be read as a match. + const truncated = [_]u8{ 0x25, 0xff, 0x1f, 0x00 }; + var sites: [4]Site = undefined; + try testing.expectEqual(@as(usize, 0), findMaskSites(&truncated, stock_frames, &sites)); +} diff --git a/packages/gs-seats/gs_seats.zig b/packages/gs-seats/gs_seats.zig index 3e79b1d6..a78f035b 100644 --- a/packages/gs-seats/gs_seats.zig +++ b/packages/gs-seats/gs_seats.zig @@ -1,21 +1,63 @@ -//! Join context — bridges the account gap in Game.exe's dedicated-server path. +//! Which account owns a character seated on this game server, for as long as it is seated. //! -//! The realm's JOINGAME dispatch carries the account for the joining character, but the -//! engine's join path (GAMELOGON -> SrvJoinGame -> fpGetDatabaseCharacter) only carries char name -//! and token, never the account — so we stash realmd's mapping here and resolve it (by char name -//! or token) when the engine asks for the character save. +//! Every save the engine makes is keyed by ACCOUNT, and the engine never carries one: its join +//! path (GAMELOGON -> join -> the character fetch) has the character name and a token and nothing +//! else. The realm's JOINGAME is the only place the pairing is ever stated, so it is stashed here +//! and read back when the engine asks for a save. //! -//! Writer: the queue thread (realmclient/d2cs.zig handleJoinGame). Reader: engine network thread -//! (engine/realm.zig fpGetDatabaseCharacter). Entries publish via an atomic `ready` flag so the -//! reader never sees a half-written slot; joins are rare so a small last-wins ring is plenty. +//! It is asked TWICE, and the second time is what makes the lifetime a correctness property rather +//! than a convenience. The character fetch asks on the way in; the save callback asks again on +//! every save for the rest of the session — every ~20 seconds once the autosave interval is +//! shortened, and again on the way out. A character whose entry is gone by then is saved under the +//! WRONG KEY: a well-formed save at an address no login path reads, reported as a success, leaving +//! the player rolled back to their last correctly-keyed save with nothing in the log. So an entry +//! belongs to the player for as long as they are seated, and only a player who has left may be +//! evicted to make room. +//! +//! Shared by both servers on purpose. `apps/d2gs` (the 1.14d DLL) and `apps/d2gs-native` (the Mac +//! image) have the same gap and used to be one table and no table respectively; two of these +//! drifting apart is two different rollback bugs. +//! +//! Writers: the realm queue thread (JOINGAME) and the engine thread (seat/release). Readers: the +//! engine's threads. Entries publish via an atomic `ready` flag so a reader never sees a +//! half-written slot, and reads COPY the account out rather than borrowing it — a slice into the +//! table is only valid until the slot is reused. const std = @import("std"); +const builtin = @import("builtin"); + +const windows = struct { + extern "kernel32" fn GetTickCount() callconv(.winapi) u32; +}; + +/// Milliseconds since boot, from whatever this host has. Windows gets the engine's own clock; the +/// Mac host and the tests get a counter, which is what lets the TTL and eviction rules below be +/// asserted without an engine at all. +var soft_ticks: u32 = 0; + +fn ticks() u32 { + return if (builtin.os.tag == .windows) windows.GetTickCount() else soft_ticks; +} + +/// Advance the software clock. On a non-Windows host this IS the clock, so the Mac server calls it +/// from its own tick; the tests use it to age entries without waiting. +pub fn advanceClock(ms: u32) void { + soft_ticks +%= ms; +} -extern "kernel32" fn GetTickCount() callconv(.winapi) u32; +/// Longest account name the realm issues. Anything longer cannot be keyed and is refused rather +/// than truncated: a truncated account is a save written where nobody looks. +pub const max_account = 31; +/// Longest character name D2 allows (15 + terminator). +pub const max_char = 15; const Entry = struct { ready: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), consumed: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + /// The player holds a seat in a game on this server, so their account is still needed by + /// every save the engine makes for them. Set when the character save is fetched, cleared + /// when the engine reports them gone. Only a cleared entry may be recycled. + seated: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), token: u32 = 0, /// The engine gameid realmd authorized this join for. Kept BESIDE the token because the /// two are different namespaces and only the gameid reaches the engine: d2ingress rewrites @@ -23,9 +65,18 @@ const Entry = struct { /// handed a gameid. See validateGame. gameid: u32 = 0, tick_ms: u32 = 0, - char: [16]u8 = undefined, + /// The store version this character's save was read at, and the one the next save is fenced + /// against. Kept HERE because it is per-session state with exactly the same lifetime as the + /// account: both are established at the fetch and both are needed by every save afterwards. + /// 0 = we never loaded it, so nothing may be fenced on it. + ver: u64 = 0, + /// When this entry stopped being needed — set when the player leaves. It orders eviction, and + /// it is deliberately NOT `tick_ms`: that one is the join's TTL, and reusing it here would + /// extend the window a token stays valid for every time somebody left a game. + idle_since_ms: u32 = 0, + char: [max_char]u8 = undefined, char_len: usize = 0, - account: [32]u8 = undefined, + account: [max_account]u8 = undefined, account_len: usize = 0, // Guild tag realmd resolved for this player (the cut Guild Halls feature). Empty // = not in a guild. The GS uses it for in-game guild display. @@ -39,12 +90,18 @@ const Entry = struct { /// token can't be used to join. pub const TOKEN_TTL_MS: u32 = 120_001; -var entries: [16]Entry = blk: { - var e: [16]Entry = undefined; +/// One per seat this server can hold: the engine admits eight clients per game and Fog's pool +/// managers cap the server at eight games, so sixty-four is every player who can be in a game +/// here at once. The old table held sixteen and was a plain round-robin ring, which meant the +/// seventeenth JOINGAME overwrote a player who was still in a game — and the first save after +/// that went to `realmd:char::`, a key nothing reads. That is the rollback. +const capacity = 64; + +var entries: [capacity]Entry = blk: { + var e: [capacity]Entry = undefined; for (&e) |*slot| slot.* = .{}; break :blk e; }; -var next: usize = 0; fn eqlIgnoreCase(a: []const u8, b: []const u8) bool { if (a.len != b.len) return false; @@ -54,50 +111,185 @@ fn eqlIgnoreCase(a: []const u8, b: []const u8) bool { return true; } -/// Record realmd's account/char/token/gameid (+ guild tag) for an imminent join (last-wins ring). -pub fn remember(token: u32, gameid: u32, charname: []const u8, account: []const u8, guild_tag: []const u8) void { - const slot = &entries[next % entries.len]; - next +%= 1; +fn find(charname: []const u8) ?*Entry { + for (&entries) |*slot| { + if (!slot.ready.load(.acquire)) continue; + if (eqlIgnoreCase(slot.char[0..slot.char_len], charname)) return slot; + } + return null; +} + +/// Is `charname` currently seated here for some account OTHER than `account`? +/// +/// This realm does not make character names globally unique — the create-time check is +/// `getCharD2s(account, name)`, scoped to one account — so two players really can both have a +/// "Sorc". Everything downstream of here keys a character by NAME: this table, the save-dedupe +/// table, and, on the Mac server, the engine's own `.d2s`. Two of them in one +/// server at the same time is not a rollback, it is a character swap: one player's save filed +/// under the other's account. +/// +/// So the name is treated as exclusive FOR AS LONG AS SOMEBODY IS PLAYING IT HERE, and the second +/// join is refused. A name whose player has left is free again — the entry is only a mapping at +/// that point, and the realm's own character lock is what stops the same character being in two +/// games at once. +fn seatedForOtherAccount(charname: []const u8, account: []const u8) bool { + for (&entries) |*slot| { + if (!slot.ready.load(.acquire)) continue; + if (!slot.seated.load(.acquire)) continue; + if (!eqlIgnoreCase(slot.char[0..slot.char_len], charname)) continue; + if (!eqlIgnoreCase(slot.account[0..slot.account_len], account)) return true; + } + return false; +} + +/// Where a `remember` for this character may go: its own entry if it has one (a rejoin replaces +/// its mapping rather than consuming a second seat), then any never-used entry, then the oldest +/// entry whose player has left. Null when every seat is held by somebody still in a game — which +/// is a full server, and the join is refused rather than served by throwing a live player's +/// account away. +fn slotFor(charname: []const u8) ?*Entry { + if (find(charname)) |own| return own; + for (&entries) |*slot| { + if (!slot.ready.load(.acquire)) return slot; + } + // Longest gone wins. Ordering by the JOIN time would take the entry of the player who has + // been on this server longest — who is also the one with the most to lose — the moment they + // stepped out of a game. + var oldest: ?*Entry = null; + const now = ticks(); + for (&entries) |*slot| { + if (slot.seated.load(.acquire)) continue; + const idle = now -% slot.idle_since_ms; + if (oldest) |o| { + if (idle <= now -% o.idle_since_ms) continue; + } + oldest = slot; + } + return oldest; +} + +/// Record realmd's account/char/token/gameid (+ guild tag) for an imminent join. +/// +/// False when the name does not fit or every seat is taken by a player still in a game; the +/// caller refuses the join, because a join whose account we cannot keep is a join whose saves +/// will be lost. +pub fn remember(token: u32, gameid: u32, charname: []const u8, account: []const u8, guild_tag: []const u8) bool { + if (charname.len == 0 or charname.len > max_char) return false; + if (account.len == 0 or account.len > max_account) return false; + // Someone else is playing a character by this name here right now. See seatedForOtherAccount: + // admitting both would file one player's saves under the other's account. + if (seatedForOtherAccount(charname, account)) return false; + const slot = slotFor(charname) orelse return false; slot.ready.store(false, .release); - const cn = @min(charname.len, slot.char.len); - @memcpy(slot.char[0..cn], charname[0..cn]); - slot.char_len = cn; - const an = @min(account.len, slot.account.len); - @memcpy(slot.account[0..an], account[0..an]); - slot.account_len = an; + @memcpy(slot.char[0..charname.len], charname); + slot.char_len = charname.len; + @memcpy(slot.account[0..account.len], account); + slot.account_len = account.len; const gn = @min(guild_tag.len, slot.guild.len); @memcpy(slot.guild[0..gn], guild_tag[0..gn]); slot.guild_len = gn; slot.token = token; slot.gameid = gameid; - slot.tick_ms = GetTickCount(); + slot.tick_ms = ticks(); + slot.idle_since_ms = slot.tick_ms; + // A fresh authorisation, so nothing has been loaded for it yet. Carrying the previous + // session's version over would fence this session's saves against a number that has since + // moved, and every one of them would be refused. + slot.ver = 0; slot.consumed.store(false, .release); slot.ready.store(true, .release); + return true; } -/// Resolve the account for a joining character (case-insensitive). Returns a -/// slice into the cache (stable for the process) or null if unknown. -pub fn accountForChar(charname: []const u8) ?[]const u8 { +/// Record the store version a character's save was loaded at. +/// +/// Every later save is refused by the store unless it still matches, so this is what makes a +/// rollback impossible rather than merely unlikely: bytes built from version N can only ever land +/// on a store still at version N. +pub fn setVersion(charname: []const u8, ver: u64) void { + if (find(charname)) |slot| slot.ver = ver; +} + +/// The version to fence this character's next save against, 0 if unknown. +pub fn version(charname: []const u8) u64 { + const slot = find(charname) orelse return 0; + return slot.ver; +} + +/// The character is in a game on this server: hold its account until it leaves. Called once the +/// engine has actually taken the save, which is the point from which it will ask us to save it +/// back. +pub fn seat(charname: []const u8) void { + if (find(charname)) |slot| slot.seated.store(true, .release); +} + +/// The character left. The entry STAYS — the engine's last save for a departing player runs +/// after the leave is reported, and dropping the mapping here would lose exactly the save that +/// matters most — it just becomes the first thing a later join may recycle. +pub fn release(charname: []const u8) void { + if (find(charname)) |slot| { + slot.idle_since_ms = ticks(); + slot.seated.store(false, .release); + } +} + +/// Every character this server is still responsible for, with the account it belongs to. +/// +/// Includes players who have LEFT but whose entry has not yet been recycled, which is deliberate: +/// the engine writes a departing player's last save after the leave, and a caller that only walked +/// the seated ones would stop watching a character one poll before its most important save. +/// +/// The slices are borrowed for the duration of the call. That is safe for exactly the reason the +/// eviction rule exists — a seated entry is never recycled — and for a departed one the window is +/// a single synchronous callback. +pub fn forEachRemembered(ctx: anytype, f: *const fn (@TypeOf(ctx), charname: []const u8, account: []const u8) void) void { for (&entries) |*slot| { if (!slot.ready.load(.acquire)) continue; - if (eqlIgnoreCase(slot.char[0..slot.char_len], charname)) { - return slot.account[0..slot.account_len]; - } + if (slot.char_len == 0 or slot.account_len == 0) continue; + f(ctx, slot.char[0..slot.char_len], slot.account[0..slot.account_len]); } - return null; } -/// Resolve the guild tag for a joining character (case-insensitive). Returns the -/// tag (e.g. "HON") or null if the player is in no guild / is unknown. -pub fn guildForChar(charname: []const u8) ?[]const u8 { +/// Release every seat held for `gameid` — the game ended, so nobody in it is still playing here. +/// +/// For a server that is told a game emptied but not which characters were in it. The mapping is +/// kept, exactly as `release` keeps it: the last save of the last player out is written after the +/// game is gone, and it still has to be filed under the right account. +pub fn releaseByGame(gameid: u32) usize { + var n: usize = 0; for (&entries) |*slot| { if (!slot.ready.load(.acquire)) continue; - if (eqlIgnoreCase(slot.char[0..slot.char_len], charname)) { - if (slot.guild_len == 0) return null; - return slot.guild[0..slot.guild_len]; - } + if (slot.gameid != gameid) continue; + if (!slot.seated.load(.acquire)) continue; + slot.idle_since_ms = ticks(); + slot.seated.store(false, .release); + n += 1; } - return null; + return n; +} + +/// Resolve the account for a character (case-insensitive), copied into `out`. Null if unknown. +/// +/// Copied, not borrowed: the table is written by other threads, so a slice into it can be +/// rewritten under a caller that is still holding it — and the value being rewritten is the one +/// that decides which key a save lands under. +pub fn accountForChar(charname: []const u8, out: []u8) ?[]const u8 { + const slot = find(charname) orelse return null; + if (slot.account_len == 0 or slot.account_len > out.len) return null; + @memcpy(out[0..slot.account_len], slot.account[0..slot.account_len]); + // Re-check: a `remember` that landed between the find and the copy leaves `out` holding a + // torn value, and a torn account is a lost save. + if (!eqlIgnoreCase(slot.char[0..slot.char_len], charname)) return null; + return out[0..slot.account_len]; +} + +/// Resolve the guild tag for a character (case-insensitive), copied into `out`. Null if the +/// player is in no guild / is unknown. +pub fn guildForChar(charname: []const u8, out: []u8) ?[]const u8 { + const slot = find(charname) orelse return null; + if (slot.guild_len == 0 or slot.guild_len > out.len) return null; + @memcpy(out[0..slot.guild_len], slot.guild[0..slot.guild_len]); + return out[0..slot.guild_len]; } /// True when the realm issued a join for this character that is still inside @@ -105,20 +297,19 @@ pub fn guildForChar(charname: []const u8) ?[]const u8 { /// holds from an earlier game: only realmd writes these entries, so a client can't /// name someone else's character to have them thrown out of the game they are in. pub fn hasFreshJoin(charname: []const u8) bool { - const now = GetTickCount(); - for (&entries) |*slot| { - if (!slot.ready.load(.acquire)) continue; - if (!eqlIgnoreCase(slot.char[0..slot.char_len], charname)) continue; - if ((now -% slot.tick_ms) < TOKEN_TTL_MS) return true; // u32 wrap, as validate() - } - return false; + const now = ticks(); + const slot = find(charname) orelse return false; + return (now -% slot.tick_ms) < TOKEN_TTL_MS; // u32 wrap, as validate() } -/// Resolve the account for a join token. Returns null if unknown. -pub fn accountForToken(token: u32) ?[]const u8 { +/// Resolve the account for a join token, copied into `out`. Null if unknown. +pub fn accountForToken(token: u32, out: []u8) ?[]const u8 { for (&entries) |*slot| { if (!slot.ready.load(.acquire)) continue; - if (slot.token == token) return slot.account[0..slot.account_len]; + if (slot.token != token) continue; + if (slot.account_len == 0 or slot.account_len > out.len) return null; + @memcpy(out[0..slot.account_len], slot.account[0..slot.account_len]); + return out[0..slot.account_len]; } return null; } @@ -132,7 +323,7 @@ pub fn accountForToken(token: u32) ?[]const u8 { /// before the GS sees it, so matching a gameid against stored realm tokens would compare two /// unrelated namespaces. pub fn validateGame(gameid: u32) bool { - const now = GetTickCount(); + const now = ticks(); for (&entries) |*slot| { if (!slot.ready.load(.acquire)) continue; if (slot.consumed.load(.acquire)) continue; @@ -154,3 +345,309 @@ pub fn consumeGame(gameid: u32) void { } } } + +/// Test-only: empty the table between cases. +pub fn resetForTest() void { + for (&entries) |*slot| slot.* = .{}; + soft_ticks = 0; +} + +// ── tests ──────────────────────────────────────────────────────────────────── +// +// These are the rollback regression. The table's only job is to still know a player's account at +// the moment the engine hands us their save, which happens minutes after the join and again on +// the way out — so every case below is about an entry SURVIVING something, not about it being +// written correctly in the first place. + +const testing = std.testing; + +fn nameFor(buf: []u8, i: usize) []const u8 { + return std.fmt.bufPrint(buf, "c{d}", .{i}) catch unreachable; +} + +fn acct(buf: []u8, i: usize) []const u8 { + return std.fmt.bufPrint(buf, "acct{d}", .{i}) catch unreachable; +} + +test "an account round-trips, and the character name is matched case-insensitively" { + resetForTest(); + try testing.expect(remember(1, 100, "Sorc", "jaenster", "")); + + var out: [max_account]u8 = undefined; + try testing.expectEqualStrings("jaenster", accountForChar("Sorc", &out).?); + try testing.expectEqualStrings("jaenster", accountForChar("sorc", &out).?); + try testing.expectEqualStrings("jaenster", accountForChar("SORC", &out).?); + try testing.expect(accountForChar("Barb", &out) == null); +} + +test "a seated player's account survives a full table of later joins" { + // The bug: the table was a 16-slot round-robin ring, so the 17th JOINGAME overwrote a player + // who was still in a game. Their next save — up to 5.5 minutes later — was written under the + // fallback account and lost, and they logged back in rolled back to before that session. + resetForTest(); + try testing.expect(remember(1, 100, "Sorc", "jaenster", "")); + seat("Sorc"); + + var nb: [16]u8 = undefined; + var ab: [16]u8 = undefined; + var admitted: usize = 0; + for (0..capacity * 4) |i| { + if (remember(@intCast(i + 2), 200, nameFor(&nb, i), acct(&ab, i), "")) admitted += 1; + advanceClock(1000); + } + try testing.expect(admitted > capacity); // they were served, not merely refused + + var out: [max_account]u8 = undefined; + try testing.expectEqualStrings("jaenster", accountForChar("Sorc", &out).?); +} + +test "a rejoin replaces the character's own entry instead of taking a second seat" { + resetForTest(); + try testing.expect(remember(1, 100, "Sorc", "jaenster", "")); + seat("Sorc"); + // Same character, new game, new token: the realm re-authorised it, so the mapping updates. + try testing.expect(remember(2, 101, "Sorc", "jaenster", "")); + + // If that had consumed a second slot, 63 more distinct seated characters would not fit. + var nb: [16]u8 = undefined; + var ab: [16]u8 = undefined; + for (0..capacity - 1) |i| { + try testing.expect(remember(@intCast(i + 3), 200, nameFor(&nb, i), acct(&ab, i), "")); + seat(nameFor(&nb, i)); + } + var out: [max_account]u8 = undefined; + try testing.expectEqualStrings("jaenster", accountForChar("Sorc", &out).?); +} + +test "a join is refused, not served by evicting somebody still in a game" { + resetForTest(); + var nb: [16]u8 = undefined; + var ab: [16]u8 = undefined; + for (0..capacity) |i| { + try testing.expect(remember(@intCast(i + 1), 200, nameFor(&nb, i), acct(&ab, i), "")); + seat(nameFor(&nb, i)); + } + // Every seat is held by a player in a game. Taking one would silently break their saves. + try testing.expect(!remember(999, 300, "Latecomer", "someone", "")); + + var out: [max_account]u8 = undefined; + for (0..capacity) |i| { + try testing.expectEqualStrings(acct(&ab, i), accountForChar(nameFor(&nb, i), &out).?); + } +} + +test "a departed player's account is still there for the save that follows the leave" { + // The engine's last save for a client runs from CleanUpClient, AFTER the leave is reported. + // Releasing the seat must not take the mapping with it. + resetForTest(); + try testing.expect(remember(1, 100, "Sorc", "jaenster", "")); + seat("Sorc"); + release("Sorc"); + + var out: [max_account]u8 = undefined; + try testing.expectEqualStrings("jaenster", accountForChar("Sorc", &out).?); +} + +test "eviction takes the longest-gone entry, not the longest-joined" { + // Ordering by join time would recycle the player who has been on this server longest — the + // one with the most to lose — the moment they stepped out of a game. + resetForTest(); + try testing.expect(remember(1, 100, "Early", "early_acct", "")); // joined first + seat("Early"); + advanceClock(10_000); + try testing.expect(remember(2, 100, "Late", "late_acct", "")); // joined second + seat("Late"); + + advanceClock(10_000); + release("Late"); // ...but left first + advanceClock(10_000); + release("Early"); + + // Fill the rest with players who are still in games, so eviction has only these two to pick from. + var nb: [16]u8 = undefined; + var ab: [16]u8 = undefined; + for (0..capacity - 2) |i| { + try testing.expect(remember(@intCast(i + 3), 200, nameFor(&nb, i), acct(&ab, i), "")); + seat(nameFor(&nb, i)); + } + + try testing.expect(remember(500, 300, "Newcomer", "new_acct", "")); + + var out: [max_account]u8 = undefined; + try testing.expect(accountForChar("Late", &out) == null); // gone longest, recycled + try testing.expectEqualStrings("early_acct", accountForChar("Early", &out).?); + try testing.expectEqualStrings("new_acct", accountForChar("Newcomer", &out).?); +} + +test "a name that does not fit is refused rather than truncated" { + // A truncated account is a save written where nobody looks — the same silent rollback the + // fallback used to cause, one step further along. + resetForTest(); + try testing.expect(!remember(1, 100, "", "jaenster", "")); + try testing.expect(!remember(1, 100, "Sorc", "", "")); + try testing.expect(!remember(1, 100, "ThisNameIsFarTooLong", "jaenster", "")); + try testing.expect(!remember(1, 100, "Sorc", "a" ** (max_account + 1), "")); + try testing.expect(remember(1, 100, "a" ** max_char, "a" ** max_account, "")); +} + +test "accountForChar refuses a buffer the account does not fit in" { + resetForTest(); + try testing.expect(remember(1, 100, "Sorc", "jaenster", "")); + var small: [4]u8 = undefined; + try testing.expect(accountForChar("Sorc", &small) == null); +} + +test "the guild tag rides along and is optional" { + resetForTest(); + try testing.expect(remember(1, 100, "Sorc", "jaenster", "HON")); + try testing.expect(remember(2, 101, "Barb", "jaenster", "")); + var out: [8]u8 = undefined; + try testing.expectEqualStrings("HON", guildForChar("Sorc", &out).?); + try testing.expect(guildForChar("Barb", &out) == null); +} + +test "a join is valid only until the token TTL runs out" { + resetForTest(); + try testing.expect(remember(1, 0xabc, "Sorc", "jaenster", "")); + try testing.expect(validateGame(0xabc)); + try testing.expect(hasFreshJoin("Sorc")); + try testing.expect(!validateGame(0xdef)); // never issued + + advanceClock(TOKEN_TTL_MS); + try testing.expect(!validateGame(0xabc)); + try testing.expect(!hasFreshJoin("Sorc")); + // Expiry is about authorising a join. The account outlives it, because the saves do. + var out: [max_account]u8 = undefined; + try testing.expectEqualStrings("jaenster", accountForChar("Sorc", &out).?); +} + +test "a consumed join cannot be replayed" { + resetForTest(); + try testing.expect(remember(1, 0xabc, "Sorc", "jaenster", "")); + try testing.expect(validateGame(0xabc)); + consumeGame(0xabc); + try testing.expect(!validateGame(0xabc)); +} + +test "a token resolves an account too" { + resetForTest(); + try testing.expect(remember(0x1234, 100, "Sorc", "jaenster", "")); + var out: [max_account]u8 = undefined; + try testing.expectEqualStrings("jaenster", accountForToken(0x1234, &out).?); + try testing.expect(accountForToken(0x9999, &out) == null); +} + +test "iteration covers departed players, whose last save has not been written yet" { + resetForTest(); + try testing.expect(remember(1, 100, "Stayer", "acct_a", "")); + seat("Stayer"); + try testing.expect(remember(2, 100, "Leaver", "acct_b", "")); + seat("Leaver"); + release("Leaver"); + + const Seen = struct { + var chars: [8][16]u8 = undefined; + var n: usize = 0; + fn visit(_: void, charname: []const u8, account: []const u8) void { + _ = account; + @memcpy(chars[n][0..charname.len], charname); + chars[n][charname.len] = 0; + n += 1; + } + }; + Seen.n = 0; + forEachRemembered({}, Seen.visit); + try testing.expectEqual(@as(usize, 2), Seen.n); +} + +test "closing a game releases its seats without forgetting who they were" { + resetForTest(); + try testing.expect(remember(1, 700, "InGame", "acct_a", "")); + seat("InGame"); + try testing.expect(remember(2, 701, "Elsewhere", "acct_b", "")); + seat("Elsewhere"); + + try testing.expectEqual(@as(usize, 1), releaseByGame(700)); + // The account survives: the engine's last save for a departing player lands after this. + var out: [max_account]u8 = undefined; + try testing.expectEqualStrings("acct_a", accountForChar("InGame", &out).?); + + // ...and the other game is untouched, so its player still cannot be evicted. + var nb: [16]u8 = undefined; + var ab: [16]u8 = undefined; + for (0..capacity) |i| _ = remember(@intCast(i + 3), 800, nameFor(&nb, i), acct(&ab, i), ""); + try testing.expectEqualStrings("acct_b", accountForChar("Elsewhere", &out).?); +} + +test "releasing a game that holds nothing is not an error" { + resetForTest(); + try testing.expectEqual(@as(usize, 0), releaseByGame(12345)); +} + +test "a second account's same-named character is refused while the first is playing" { + // Character names are unique per ACCOUNT on this realm, not globally, and everything that + // keys a character by name alone — this table, the save dedupe, the Mac engine's own + // .d2s — would otherwise file one player's save under the other's account. + resetForTest(); + try testing.expect(remember(1, 100, "Sorc", "alice", "")); + seat("Sorc"); + try testing.expect(!remember(2, 101, "Sorc", "bob", "")); + + // Alice keeps hers, unambiguously. + var out: [max_account]u8 = undefined; + try testing.expectEqualStrings("alice", accountForChar("Sorc", &out).?); +} + +test "the same account's character is not blocked by the rule" { + // A rejoin is the common case and must still work. + resetForTest(); + try testing.expect(remember(1, 100, "Sorc", "alice", "")); + seat("Sorc"); + try testing.expect(remember(2, 101, "Sorc", "alice", "")); + try testing.expect(remember(3, 102, "SORC", "ALICE", "")); // and case does not change that +} + +test "a name is free again once its player has left" { + resetForTest(); + try testing.expect(remember(1, 100, "Sorc", "alice", "")); + seat("Sorc"); + release("Sorc"); + try testing.expect(remember(2, 101, "Sorc", "bob", "")); + var out: [max_account]u8 = undefined; + try testing.expectEqualStrings("bob", accountForChar("Sorc", &out).?); +} + +test "a name collision cannot make one player's save land under another's account" { + // The property that matters, stated directly: whatever the table admits, a lookup by name + // never returns an account that does not own a SEATED character by that name. + resetForTest(); + try testing.expect(remember(1, 100, "Sorc", "alice", "")); + seat("Sorc"); + _ = remember(2, 101, "Sorc", "bob", ""); + _ = remember(3, 102, "Sorc", "carol", ""); + var out: [max_account]u8 = undefined; + try testing.expectEqualStrings("alice", accountForChar("Sorc", &out).?); +} + +test "the load version is remembered for the session and cleared by a new one" { + resetForTest(); + try testing.expect(remember(1, 100, "Sorc", "alice", "")); + try testing.expectEqual(@as(u64, 0), version("Sorc")); // nothing loaded yet + setVersion("Sorc", 7); + try testing.expectEqual(@as(u64, 7), version("Sorc")); + seat("Sorc"); + setVersion("Sorc", 8); // a save succeeded and moved it on + try testing.expectEqual(@as(u64, 8), version("Sorc")); + + // A new authorisation starts over: the store may have moved while they were away, and fencing + // against the old number would have every save this session refused. + try testing.expect(remember(2, 101, "Sorc", "alice", "")); + try testing.expectEqual(@as(u64, 0), version("Sorc")); +} + +test "an unknown character has no version to fence on" { + resetForTest(); + try testing.expectEqual(@as(u64, 0), version("Nobody")); + setVersion("Nobody", 5); // must not create an entry + try testing.expectEqual(@as(u64, 0), version("Nobody")); +} diff --git a/packages/gs-store/gs_store.zig b/packages/gs-store/gs_store.zig index 5dc248ee..7aa561fb 100644 --- a/packages/gs-store/gs_store.zig +++ b/packages/gs-store/gs_store.zig @@ -6,6 +6,7 @@ //! socket carries a receive timeout and a failed op returns rather than retrying mid-tick. const std = @import("std"); const resp = @import("resp"); +const savequeue = @import("savequeue.zig"); const SOCKET = usize; const INVALID_SOCKET: SOCKET = ~@as(usize, 0); @@ -41,6 +42,7 @@ const hostent = extern struct { h_addr_list: ?[*]const ?*const u32, }; extern "kernel32" fn Sleep(ms: u32) callconv(.winapi) void; +extern "kernel32" fn GetTickCount() callconv(.winapi) u32; const INADDR_NONE: u32 = 0xffff_ffff; @@ -253,6 +255,42 @@ fn readReply(s: SOCKET) ?Reply { // the operations the game server actually needs +/// A character as this server took it: the bytes, and the store version they were at. +pub const Loaded = struct { + len: usize, + /// The value of `realmd:charver:/` when these bytes were read. Every save this server + /// later makes is fenced against it, so a save built from THESE bytes can never land on top of + /// somebody else's newer ones. 0 means the store had no version — a character that has never + /// been saved, or a redis that lost its state. + ver: u64, +}; + +/// Read a character and the version it is at. +/// +/// The VERSION IS READ FIRST, and that order is the whole point. Read the other way round, a save +/// landing between the two reads gives us its bytes while we record the older version — and the +/// fence would then happily let us overwrite those newer bytes with something built from them. +/// This way the same race gives us a version older than our bytes, and our next save is refused: +/// conservative, which is the direction a save fence must fail in. +pub fn getCharVersioned(account: []const u8, charname: []const u8, out: []u8) Loaded { + const ver = charVersion(account, charname); + return .{ .len = getChar(account, charname, out), .ver = ver }; +} + +/// The store's current version for a character, 0 if it has none. +pub fn charVersion(account: []const u8, charname: []const u8) u64 { + var vb: [192]u8 = undefined; + const verkey = std.fmt.bufPrint(&vb, "realmd:charver:{s}/{s}", .{ account, charname }) catch return 0; + const rep = command(&.{ "GET", verkey }) orelse return 0; + return switch (rep.value) { + .bulk => |b| blk: { + const v = b orelse break :blk 0; + break :blk std.fmt.parseInt(u64, v, 10) catch 0; + }, + else => 0, + }; +} + /// Fetch a character save into `out`, returning its length. 0 if absent or unreadable. pub fn getChar(account: []const u8, charname: []const u8, out: []u8) usize { var kb: [192]u8 = undefined; @@ -272,9 +310,94 @@ pub fn getChar(account: []const u8, charname: []const u8, out: []u8) usize { }; } +/// The characters realmd's `sanitize` accepts in a key. A name outside this set is one realmd +/// will refuse to read back, so writing it here would store a save at an address the login path +/// can never reach — the same silent rollback as writing under the wrong account, one step +/// further along. Refuse it on this side too, where the save still exists to complain about. +pub fn keyable(name: []const u8) bool { + if (name.len == 0 or name.len > 63) return false; + for (name) |c| { + const ok = (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or + (c >= '0' and c <= '9') or c == '_' or c == '-'; + if (!ok) return false; + } + return true; +} + +/// What happened to a fenced save. +pub const SaveResult = union(enum) { + /// Stored; the character is now at this version. + stored: u64, + /// REFUSED because the store has moved on: somebody wrote this character after we loaded it. + /// Our bytes are older than what is there, and writing them would be a rollback. Never retry + /// one of these — the save is genuinely obsolete. + stale: u64, + /// The store could not be reached or did not answer. Nothing is known and nothing was written; + /// this one IS worth retrying. + unavailable, +}; + +/// Store a character save, but only if nothing has written it since we loaded it. +/// +/// This is the mechanism that makes a rollback structurally impossible rather than merely +/// unlikely. Every other guard in this repo — the seat table, the retry queue's age bound, the +/// character lock, refusing to delete a character in a game — narrows the ways older bytes can +/// reach the store. This one closes the question: the store itself will not accept them. +/// +/// `expect` is the version this server read with the character. The whole compare-set-increment +/// runs inside redis, so nothing can interleave between the check and the write. +/// +/// A current version of ZERO is accepted whatever `expect` says. It means the store has no version +/// for this character: either it has never been saved, or redis lost its state and was refilled +/// from postgres. In both cases a live session's bytes are the newest thing in existence, and +/// refusing them would turn a cache flush into the very data loss this exists to prevent. The +/// realm's character lock is what keeps two servers from being in that position at once. +pub fn putCharFenced(account: []const u8, charname: []const u8, save: []const u8, expect: u64) SaveResult { + if (!keyable(account) or !keyable(charname)) return .unavailable; + var kb: [192]u8 = undefined; + const key = std.fmt.bufPrint(&kb, "realmd:char:{s}:{s}", .{ account, charname }) catch return .unavailable; + var vb: [192]u8 = undefined; + const verkey = std.fmt.bufPrint(&vb, "realmd:charver:{s}/{s}", .{ account, charname }) catch return .unavailable; + var sb: [192]u8 = undefined; + const setkey = std.fmt.bufPrint(&sb, "realmd:chars:{s}", .{account}) catch return .unavailable; + var mb: [192]u8 = undefined; + const member = std.fmt.bufPrint(&mb, "{s}/{s}", .{ account, charname }) catch return .unavailable; + var eb: [24]u8 = undefined; + const expect_s = std.fmt.bufPrint(&eb, "{d}", .{expect}) catch return .unavailable; + + // Returns the new version on success, or -(current+1) when it refuses, so the caller learns + // what the store is actually at without a second round trip. + const script = + \\local cur = tonumber(redis.call('GET', KEYS[2]) or '0') + \\if cur ~= 0 and cur ~= tonumber(ARGV[1]) then return -(cur + 1) end + \\redis.call('SET', KEYS[1], ARGV[4]) + \\redis.call('SADD', KEYS[3], ARGV[2]) + \\redis.call('SADD', KEYS[4], ARGV[3]) + \\return redis.call('INCR', KEYS[2]) + ; + const rep = commandBig(&.{ + "EVAL", script, "4", key, + verkey, setkey, "realmd:dirty", + expect_s, charname, member, + }, save) orelse return .unavailable; + return switch (rep.value) { + .int => |v| if (v > 0) + .{ .stored = @intCast(v) } + else + .{ .stale = @intCast(-v - 1) }, + else => .unavailable, + }; +} + /// Store a character save and mark it for the realm's flush worker. Both, or neither — a save /// redis takes but nobody is told about would sit there while postgres fell behind. +/// +/// The account's character set is written too. realmd's own save does it (`saveCharD2s`: SET + +/// SADD), and that set is what `listChars` reads: a character whose newest bytes are only in +/// redis and whose name is not in the set is one the character screen does not list until +/// postgres catches up. pub fn putChar(account: []const u8, charname: []const u8, bytes: []const u8) bool { + if (!keyable(account) or !keyable(charname)) return false; var kb: [192]u8 = undefined; const key = std.fmt.bufPrint(&kb, "realmd:char:{s}:{s}", .{ account, charname }) catch return false; const set = commandBig(&.{ "SET", key }, bytes) orelse return false; @@ -283,6 +406,9 @@ pub fn putChar(account: []const u8, charname: []const u8, bytes: []const u8) boo .bulk => |b| if (b == null) return false, else => return false, } + var sb: [192]u8 = undefined; + const setkey = std.fmt.bufPrint(&sb, "realmd:chars:{s}", .{account}) catch return false; + _ = command(&.{ "SADD", setkey, charname }) orelse return false; var vb: [192]u8 = undefined; const verkey = std.fmt.bufPrint(&vb, "realmd:charver:{s}/{s}", .{ account, charname }) catch return false; _ = command(&.{ "INCR", verkey }) orelse return false; @@ -292,6 +418,60 @@ pub fn putChar(account: []const u8, charname: []const u8, bytes: []const u8) boo return true; } +/// Store a character save, and if the store will not take it, keep it and try again. +/// +/// This is what every save path should call. `putChar` alone reports a failure the caller can only +/// log: the bytes belong to a buffer the engine is about to reuse, so a store that blinked for +/// half a second costs a player their session — and on the way out of a game there is no later +/// save to make up for it. Parking them costs a few KB and makes the failure a delay. +/// +/// The return value is whether it is DURABLE NOW, not whether it is safe: false with `pending()` +/// non-zero means it is queued, false with `park` having refused means it really is gone, which is +/// the only case worth shouting about. +pub fn putCharDurable(account: []const u8, charname: []const u8, save: []const u8, expect: u64) SaveResult { + const r = putCharFenced(account, charname, save, expect); + switch (r) { + .stored => { + // A success supersedes anything queued for this character: retrying an older save on + // top of a newer one is a rollback caused by the retry machinery itself. + savequeue.drop(account, charname, save.len); + }, + .stale => { + // Genuinely obsolete. Queueing it would be queueing a rollback, and anything already + // queued for this character is at least as obsolete, so that goes too. + savequeue.drop(account, charname, save.len); + }, + .unavailable => _ = savequeue.park(account, charname, save, expect, GetTickCount()), + } + return r; +} + +/// How many saves are waiting for the store to come back. Zero on a healthy server. +pub fn pending() usize { + return savequeue.depth(); +} + +/// Retry one queued save. Call from the server's own tick; one per tick is plenty, because the +/// queue is only ever non-empty while the store is unwell and hammering it does not help. +/// +/// Returns true if one was stored, so a caller can drain faster while it is making progress. +pub fn retryPending() bool { + var acct: [32]u8 = undefined; + var name: [24]u8 = undefined; + var save: [savequeue.max_bytes]u8 = undefined; + const p = savequeue.peek(&acct, &name, &save, GetTickCount()) orelse return false; + // Retried under the SAME fence it was parked with, so a queued save is no more able to + // overwrite a newer one than a fresh save is. A refusal means the character moved on while we + // were unable to write, and the queued bytes are obsolete: drop them rather than spin. + switch (putCharFenced(p.account, p.charname, p.save, p.expect)) { + .stored, .stale => { + savequeue.drop(p.account, p.charname, p.save.len); + return true; + }, + .unavailable => return false, + } +} + /// Publish this server's heartbeat: it exists, where clients reach it, and how loaded it is. /// The TTL is what makes a server that dies disappear without anyone having to notice. /// Publish this server into the realm's view. `labels` is what it says it IS — `k=v` pairs diff --git a/packages/gs-store/savequeue.zig b/packages/gs-store/savequeue.zig new file mode 100644 index 00000000..ebcce070 --- /dev/null +++ b/packages/gs-store/savequeue.zig @@ -0,0 +1,436 @@ +//! Saves the store would not take, kept until it will. +//! +//! A character save reaches redis or it does not exist. When `putChar` fails — the connection +//! dropped, a failover is in progress, the store is briefly out of memory — the bytes are in a +//! buffer the engine is about to reuse, and the old behaviour was to log a line and let them go. +//! That is a player's progress lost to a blip of a few hundred milliseconds, and the engine will +//! not offer the same save again: on the next autosave it builds a NEW one, which is fine, but on +//! the way out there is no next one. +//! +//! So a refused save is parked here and retried from the server's own tick. The queue is small and +//! bounded, holds at most one save per CHARACTER (a newer one supersedes an older — they are the +//! same character, and only the newest matters), and drops the OLDEST when it is full, because a +//! queue that refuses new entries under pressure keeps the least useful ones. +//! +//! "The same character" means the same ACCOUNT AND NAME, never the name alone. This realm does not +//! make character names globally unique — the create-time check is scoped to one account — so two +//! players can both have a "Bob". Keyed by name, one player's parked save would be treated as a +//! newer version of the other's and silently replace it, and a `drop` for one would discard the +//! other's before it was ever retried. That is a permanent, unlogged loss of somebody's session. +//! +//! Pure: no sockets, no engine. It is handed bytes and hands them back, which is what lets the +//! superseding and eviction rules below be asserted directly. + +const std = @import("std"); + +/// How many characters can be waiting at once. A store that is down for longer than this many +/// distinct characters' saves is a store that is down, and the answer to that is not more memory. +pub const capacity = 8; + +/// The largest save kept. Real saves are a few KB; this is what the engine itself will read back. +pub const max_bytes = 16 * 1024; + +/// How long a refused save is worth retrying before it is dropped instead of written. +/// +/// A queue with no age bound is itself a rollback mechanism. The save sitting here is a snapshot +/// of a moment; if this server's game has since ended and the player has gone on to play the same +/// character somewhere else, writing it lands OLD bytes on top of NEW ones — the retry causing +/// exactly what it exists to prevent. The realm's own character lock keeps a character in one game +/// at a time, so the exposure is only after this game ended, but "narrow" is not "closed". +/// +/// Two minutes: comfortably longer than a redis failover (Sentinel promotes in seconds) and short +/// enough that a player cannot have got far with the character elsewhere. Past it the save is +/// stale enough that losing it is the safer of the two mistakes. +pub const max_age_ms: u64 = 120_000; + +const name_max = 24; +const account_max = 32; + +const Slot = struct { + account: [account_max]u8 = @splat(0), + account_len: usize = 0, + name: [name_max]u8 = @splat(0), + name_len: usize = 0, + len: usize = 0, + /// Which park this was, so eviction can take the oldest without comparing clocks. + seq: u64 = 0, + /// When it was parked, for the age bound. The caller supplies the clock: this file stays pure, + /// and the three hosts that use it do not share one. + parked_ms: u64 = 0, + /// The store version this save is fenced against. A retry carries it too, so a queued save can + /// no more overwrite a newer one than a fresh save can — the age bound below is a belt to this + /// brace, not a substitute for it. + expect: u64 = 0, + used: bool = false, +}; + +var slots: [capacity]Slot = @splat(.{}); +var bytes: [capacity][max_bytes]u8 = undefined; +var seq_next: u64 = 1; + +/// How many saves are waiting. Zero on a healthy server, so it is worth reporting when it is not. +pub fn depth() usize { + var n: usize = 0; + for (&slots) |*s| { + if (s.used) n += 1; + } + return n; +} + +fn eqlIgnoreCase(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + for (a, b) |x, y| { + if (std.ascii.toLower(x) != std.ascii.toLower(y)) return false; + } + return true; +} + +/// Does this slot hold the save of exactly this character? Account AND name — see the file +/// comment for why the name alone is not an identity here. +fn isChar(s: *const Slot, account: []const u8, charname: []const u8) bool { + return s.used and + eqlIgnoreCase(s.name[0..s.name_len], charname) and + eqlIgnoreCase(s.account[0..s.account_len], account); +} + +/// Keep a save the store refused, to be retried later. +/// +/// False when it cannot be kept at all — a name or a save too big for the queue — which the caller +/// should report, because it is the one case where progress really is gone. +pub fn park(account: []const u8, charname: []const u8, save: []const u8, expect: u64, now_ms: u64) bool { + if (account.len == 0 or account.len > account_max) return false; + if (charname.len == 0 or charname.len > name_max) return false; + if (save.len == 0 or save.len > max_bytes) return false; + + // This character's own slot first: a newer save of the same character REPLACES the older one. + // Queueing both would retry a save that is already stale and then overwrite the good one with + // it, which is a rollback produced by the machinery meant to prevent one. + var target: ?usize = null; + for (&slots, 0..) |*s, i| { + if (isChar(s, account, charname)) { + target = i; + break; + } + } + if (target == null) { + for (&slots, 0..) |*s, i| { + if (!s.used) { + target = i; + break; + } + } + } + if (target == null) { + // Full. Evict the oldest: it is the save most likely to have been superseded by play the + // player has since done anyway, and dropping the NEWEST would mean a busy server never + // recovers the most recent state of anybody. + var oldest: usize = 0; + for (slots, 0..) |s, i| { + if (s.seq < slots[oldest].seq) oldest = i; + } + target = oldest; + } + + const i = target.?; + @memcpy(bytes[i][0..save.len], save); + slots[i] = .{ .used = true, .len = save.len, .seq = seq_next, .parked_ms = now_ms, .expect = expect }; + seq_next += 1; + @memcpy(slots[i].account[0..account.len], account); + slots[i].account_len = account.len; + @memcpy(slots[i].name[0..charname.len], charname); + slots[i].name_len = charname.len; + return true; +} + +pub const Parked = struct { + account: []const u8, + charname: []const u8, + save: []const u8, + /// The version this save must still be fenced against when it is retried. + expect: u64, +}; + +/// The oldest waiting save, copied into the caller's buffers, or null if there is nothing to do. +/// +/// It stays in the queue: a retry that fails must not lose what it was retrying, so the caller +/// calls `drop` only once the store has actually taken it. That is the whole point of the queue +/// and it is why this is not a pop. +pub fn peek(account_out: []u8, name_out: []u8, save_out: []u8, now_ms: u64) ?Parked { + // Drop anything too old to be safe to write before choosing what to retry. See max_age_ms: + // writing one of these could put old bytes over newer ones. + for (&slots) |*s| { + if (s.used and now_ms -% s.parked_ms > max_age_ms) { + expired_count += 1; + s.* = .{}; + } + } + var best: ?usize = null; + for (&slots, 0..) |*s, i| { + if (!s.used) continue; + if (best == null or s.seq < slots[best.?].seq) best = i; + } + const i = best orelse return null; + const s = &slots[i]; + if (s.account_len > account_out.len or s.name_len > name_out.len or s.len > save_out.len) { + // Cannot hand it back through these buffers. Drop it rather than spin on it forever. + s.* = .{}; + return null; + } + @memcpy(account_out[0..s.account_len], s.account[0..s.account_len]); + @memcpy(name_out[0..s.name_len], s.name[0..s.name_len]); + @memcpy(save_out[0..s.len], bytes[i][0..s.len]); + return .{ + .account = account_out[0..s.account_len], + .charname = name_out[0..s.name_len], + .save = save_out[0..s.len], + .expect = s.expect, + }; +} + +/// Forget a character's parked save, because the store has taken it. +/// +/// Matched by account, name AND length. The account is what keeps one player's `drop` from +/// discarding a same-named character belonging to somebody else; the length is what keeps it from +/// discarding a NEWER save of the same character that was parked while the retry was in flight. +pub fn drop(account: []const u8, charname: []const u8, save_len: usize) void { + for (&slots) |*s| { + if (isChar(s, account, charname) and s.len == save_len) s.* = .{}; + } +} + +/// Saves this queue gave up on because they aged out. Non-zero means progress really was lost, +/// which is worth a line in a log even though the alternative was worse. +pub fn expired() u64 { + return expired_count; +} + +var expired_count: u64 = 0; + +pub fn resetForTest() void { + expired_count = 0; + slots = @splat(.{}); + seq_next = 1; +} + +// ── tests ──────────────────────────────────────────────────────────────────── + +const testing = std.testing; + +fn peekInto(a: *[account_max]u8, n: *[name_max]u8, s: *[max_bytes]u8) ?Parked { + return peek(a, n, s, 0); +} + +test "a parked save comes back with its account, name and bytes intact" { + resetForTest(); + try testing.expect(park("jaenster", "Persist", "the-save", 0, 0)); + var a: [account_max]u8 = undefined; + var n: [name_max]u8 = undefined; + var b: [max_bytes]u8 = undefined; + const got = peekInto(&a, &n, &b).?; + try testing.expectEqualStrings("jaenster", got.account); + try testing.expectEqualStrings("Persist", got.charname); + try testing.expectEqualStrings("the-save", got.save); +} + +test "peek does not remove: a retry that fails must not lose what it was retrying" { + resetForTest(); + try testing.expect(park("jaenster", "Persist", "the-save", 0, 0)); + var a: [account_max]u8 = undefined; + var n: [name_max]u8 = undefined; + var b: [max_bytes]u8 = undefined; + _ = peekInto(&a, &n, &b).?; + try testing.expectEqual(@as(usize, 1), depth()); + _ = peekInto(&a, &n, &b).?; + try testing.expectEqual(@as(usize, 1), depth()); + drop("jaenster", "Persist", "the-save".len); + try testing.expectEqual(@as(usize, 0), depth()); + try testing.expect(peekInto(&a, &n, &b) == null); +} + +test "a newer save of the same character replaces the older one" { + // Retrying a stale save AFTER the newer one succeeded would overwrite good progress with old + // progress — a rollback caused by the thing meant to prevent one. + resetForTest(); + try testing.expect(park("jaenster", "Persist", "old", 0, 0)); + try testing.expect(park("jaenster", "Persist", "newer", 0, 0)); + try testing.expectEqual(@as(usize, 1), depth()); + var a: [account_max]u8 = undefined; + var n: [name_max]u8 = undefined; + var b: [max_bytes]u8 = undefined; + try testing.expectEqualStrings("newer", peekInto(&a, &n, &b).?.save); +} + +test "dropping only matches the save that was actually stored" { + // A newer save parked while the retry was in flight must survive the drop for the old one. + resetForTest(); + try testing.expect(park("jaenster", "Persist", "old", 0, 0)); + try testing.expect(park("jaenster", "Persist", "much-newer", 0, 0)); + drop("jaenster", "Persist", "old".len); // the in-flight retry finished, but for the OLD bytes + try testing.expectEqual(@as(usize, 1), depth()); + var a: [account_max]u8 = undefined; + var n: [name_max]u8 = undefined; + var b: [max_bytes]u8 = undefined; + try testing.expectEqualStrings("much-newer", peekInto(&a, &n, &b).?.save); +} + +test "different characters queue independently" { + resetForTest(); + try testing.expect(park("acct_a", "Alice", "a", 0, 0)); + try testing.expect(park("acct_b", "Bob", "b", 0, 0)); + try testing.expectEqual(@as(usize, 2), depth()); +} + +test "retries come back oldest first" { + resetForTest(); + try testing.expect(park("acct_a", "First", "1", 0, 0)); + try testing.expect(park("acct_b", "Second", "2", 0, 0)); + var a: [account_max]u8 = undefined; + var n: [name_max]u8 = undefined; + var b: [max_bytes]u8 = undefined; + try testing.expectEqualStrings("First", peekInto(&a, &n, &b).?.charname); + drop("acct_a", "First", 1); + try testing.expectEqualStrings("Second", peekInto(&a, &n, &b).?.charname); +} + +test "a full queue drops the oldest, never the newest" { + resetForTest(); + var nb: [8]u8 = undefined; + for (0..capacity) |i| { + try testing.expect(park("acct", std.fmt.bufPrint(&nb, "c{d}", .{i}) catch unreachable, "x", 0, 0)); + } + try testing.expectEqual(capacity, depth()); + try testing.expect(park("acct", "Newcomer", "x", 0, 0)); + try testing.expectEqual(capacity, depth()); + + var a: [account_max]u8 = undefined; + var n: [name_max]u8 = undefined; + var b: [max_bytes]u8 = undefined; + // c0 was the oldest and is gone; the newcomer is still here. + try testing.expectEqualStrings("c1", peekInto(&a, &n, &b).?.charname); + var found_newcomer = false; + for (&slots) |*s| { + if (s.used and std.mem.eql(u8, s.name[0..s.name_len], "Newcomer")) found_newcomer = true; + } + try testing.expect(found_newcomer); +} + +test "what cannot be kept is refused rather than half-kept" { + resetForTest(); + try testing.expect(!park("", "Persist", "x", 0, 0)); + try testing.expect(!park("jaenster", "", "x", 0, 0)); + try testing.expect(!park("jaenster", "Persist", "", 0, 0)); + const too_big = [_]u8{0} ** (max_bytes + 1); + try testing.expect(!park("jaenster", "Persist", &too_big, 0, 0)); + try testing.expectEqual(@as(usize, 0), depth()); +} + +test "a save exactly at the limit is kept" { + resetForTest(); + const at_limit = [_]u8{7} ** max_bytes; + try testing.expect(park("jaenster", "Persist", &at_limit, 0, 0)); + var a: [account_max]u8 = undefined; + var n: [name_max]u8 = undefined; + var b: [max_bytes]u8 = undefined; + try testing.expectEqual(@as(usize, max_bytes), peekInto(&a, &n, &b).?.save.len); +} + +test "a store that recovers drains the whole queue" { + resetForTest(); + var nb: [8]u8 = undefined; + for (0..capacity) |i| { + try testing.expect(park("acct", std.fmt.bufPrint(&nb, "c{d}", .{i}) catch unreachable, "xx", 0, 0)); + } + var a: [account_max]u8 = undefined; + var n: [name_max]u8 = undefined; + var b: [max_bytes]u8 = undefined; + var drained: usize = 0; + while (peekInto(&a, &n, &b)) |p| { + drop(p.account, p.charname, p.save.len); + drained += 1; + if (drained > capacity * 2) break; // guard against a peek/drop that cannot make progress + } + try testing.expectEqual(capacity, drained); + try testing.expectEqual(@as(usize, 0), depth()); +} + +test "a save too old to be safe is dropped rather than written" { + // The queue must not become the rollback. Past max_age_ms this server's game has ended and + // the player may have taken the character elsewhere; writing our snapshot would land old + // bytes on top of new ones. + resetForTest(); + try testing.expect(park("jaenster", "Persist", "stale", 0, 1000)); + var a: [account_max]u8 = undefined; + var n: [name_max]u8 = undefined; + var b: [max_bytes]u8 = undefined; + // Still inside the window: retried. + try testing.expect(peek(&a, &n, &b, 1000 + max_age_ms) != null); + // Past it: gone, and counted rather than silently vanished. + try testing.expect(peek(&a, &n, &b, 1000 + max_age_ms + 1) == null); + try testing.expectEqual(@as(usize, 0), depth()); + try testing.expectEqual(@as(u64, 1), expired()); +} + +test "a fresh save is not dropped alongside a stale one" { + resetForTest(); + try testing.expect(park("acct_a", "Old", "x", 0, 0)); + try testing.expect(park("acct_b", "New", "y", 0, max_age_ms)); + var a: [account_max]u8 = undefined; + var n: [name_max]u8 = undefined; + var b: [max_bytes]u8 = undefined; + const got = peek(&a, &n, &b, max_age_ms + 1).?; + try testing.expectEqualStrings("New", got.charname); + try testing.expectEqual(@as(usize, 1), depth()); + try testing.expectEqual(@as(u64, 1), expired()); +} + +test "re-parking a character refreshes its age, because the bytes are new" { + resetForTest(); + try testing.expect(park("jaenster", "Persist", "v1", 0, 0)); + try testing.expect(park("jaenster", "Persist", "v2", 0, max_age_ms)); + var a: [account_max]u8 = undefined; + var n: [name_max]u8 = undefined; + var b: [max_bytes]u8 = undefined; + const got = peek(&a, &n, &b, max_age_ms + 1).?; + try testing.expectEqualStrings("v2", got.save); +} + +test "two accounts with the same character name do not collide" { + // Names are unique per account on this realm, not globally. Keyed by name alone, one player's + // parked save would look like a newer version of the other's and replace it outright. + resetForTest(); + try testing.expect(park("alice", "Bob", "alice-save", 0, 0)); + try testing.expect(park("bob_acct", "Bob", "bob-save", 0, 0)); + try testing.expectEqual(@as(usize, 2), depth()); + + var a: [account_max]u8 = undefined; + var n: [name_max]u8 = undefined; + var b: [max_bytes]u8 = undefined; + const first = peek(&a, &n, &b, 0).?; + try testing.expectEqualStrings("alice", first.account); + try testing.expectEqualStrings("alice-save", first.save); + + // Dropping one must not take the other with it, even at the same length. + drop("alice", "Bob", "alice-save".len); + try testing.expectEqual(@as(usize, 1), depth()); + const second = peek(&a, &n, &b, 0).?; + try testing.expectEqualStrings("bob_acct", second.account); + try testing.expectEqualStrings("bob-save", second.save); +} + +test "a drop for the wrong account is ignored" { + resetForTest(); + try testing.expect(park("alice", "Bob", "xxxx", 0, 0)); + drop("somebody_else", "Bob", 4); + try testing.expectEqual(@as(usize, 1), depth()); +} + +test "a parked save keeps the version it must be fenced against" { + // A retry that dropped the fence would be able to do exactly what the fence exists to stop: + // land bytes from an old load on top of a newer save. + resetForTest(); + try testing.expect(park("jaenster", "Persist", "bytes", 42, 0)); + var a: [account_max]u8 = undefined; + var n: [name_max]u8 = undefined; + var b: [max_bytes]u8 = undefined; + try testing.expectEqual(@as(u64, 42), peek(&a, &n, &b, 0).?.expect); +} diff --git a/packages/realm-proto/protocol.zig b/packages/realm-proto/protocol.zig index 1a08dbd3..b27cb94e 100644 --- a/packages/realm-proto/protocol.zig +++ b/packages/realm-proto/protocol.zig @@ -148,7 +148,12 @@ pub const UpdateGameInfo = extern struct { players: u32, charlevel: u32, charclass: u32, - // followed by: cstr charname + // followed by: cstr charname, then cstr account (optional — absent from older servers) + // + // The account is what makes a departure unambiguous. Character names are unique only per + // account on this realm, so a game can hold two players whose characters are both called + // "Bob", and a leave carrying the name alone cannot say which one left. Guessing frees a lock + // belonging to somebody still playing. }; /// UPDATEGAMEINFO flag values. diff --git a/packages/realm-store/pg.zig b/packages/realm-store/pg.zig index bc3330b9..78c29623 100644 --- a/packages/realm-store/pg.zig +++ b/packages/realm-store/pg.zig @@ -312,6 +312,26 @@ fn createSchema(p: *pg.Pool) !void { // overwrite by writing the wrong key; `metadata` is free-form and belongs to whoever wrote it. // Empty version means "no engine recorded", which reads as no constraint — that is what every // character created before this column existed is. + // Character names, claimed across the whole realm. + // + // `chars` is keyed by (account, name), so two accounts could each hold a "Bob" — and everything + // downstream that identifies a character by NAME then has two answers: the game server's seat + // table, its save-retry queue, a departing player's seat release, and, on the Mac engine, the + // save file itself, which is literally `.d2s`. The result is not a + // rollback but a character swap. + // + // A separate claim table rather than a unique index on `chars`: an index would have to be + // created over data that may already contain duplicates, and that failure would happen at + // startup, in the schema bootstrap, taking the realm down. This starts empty and only ever + // grows, so it cannot fail on anything already there — and `claimCharName` checks `chars` too, + // which is what covers the characters that predate it. + _ = try p.exec( + \\create table if not exists charnames( + \\ lname text primary key, + \\ account text not null, + \\ name text not null + \\) + , .{}); _ = try p.exec("alter table chars add column if not exists version text not null default ''", .{}); _ = try p.exec("alter table chars add column if not exists metadata jsonb not null default '{}'::jsonb", .{}); // join password, player count + description (added separately so an existing table @@ -384,6 +404,48 @@ pub fn saveCharD2s(account: []const u8, charname: []const u8, bytes: []const u8) return true; } +/// Claim a character name for `account`, across the whole realm. False if somebody else has it. +/// +/// Atomic: the primary key on `lname` is what decides, so two instances racing to create the same +/// name cannot both win. Re-claiming a name this account already holds succeeds, which is what +/// lets a player delete a character and make another with the same name. +/// +/// The `chars` probe covers characters created before this table existed — they hold their names +/// without a claim row, and must still block a newcomer. +pub fn claimCharName(account: []const u8, charname: []const u8) bool { + var ab: [64]u8 = undefined; + var cb: [64]u8 = undefined; + const a = sanitize(account, &ab) orelse return false; + const c = sanitize(charname, &cb) orelse return false; + const p = ensurePool() orelse return false; + + // Somebody else already has a character by this name from before the claim table. + if (p.row("select 1 from chars where lower(name) = lower($1) and account <> $2", .{ c, a }) catch return false) |r| { + var row = r; + row.deinit() catch {}; + return false; + } + + var row = (p.row( + \\insert into charnames(lname, account, name) values (lower($1), $2, $1) + \\on conflict (lname) do update set name = excluded.name + \\where charnames.account = $2 + \\returning account + , .{ c, a }) catch return false) orelse return false; + defer row.deinit() catch {}; + return true; +} + +/// Give a character name back, so it can be taken again. Called when a character is deleted. +pub fn releaseCharName(account: []const u8, charname: []const u8) void { + var ab: [64]u8 = undefined; + var cb: [64]u8 = undefined; + const a = sanitize(account, &ab) orelse return; + const c = sanitize(charname, &cb) orelse return; + const p = ensurePool() orelse return; + _ = p.exec("delete from charnames where lname = lower($1) and account = $2", .{ c, a }) catch return; +} + pub fn getCharD2s(account: []const u8, charname: []const u8, out: []u8) usize { var ab: [64]u8 = undefined; var cb: [64]u8 = undefined; diff --git a/packages/realm-store/redis.zig b/packages/realm-store/redis.zig index 9ae0c2c9..5f087d2c 100644 --- a/packages/realm-store/redis.zig +++ b/packages/realm-store/redis.zig @@ -1705,21 +1705,66 @@ pub fn renewGameCharLeases(gameid: u32, owner: []const u8, ttl_s: u32) usize { } /// Free one character this game holds, matched by name because that is all a departure carries. +/// Release the seat this game holds for exactly this character. +/// +/// The unambiguous form of `releaseGameCharByName`, for a game server that told us which account +/// the departing player belongs to. Nothing is scanned and nothing is guessed: the member is +/// built, not matched, so a second character with the same name in the same game is untouched. +pub fn releaseGameCharExact(gameid: u32, account: []const u8, charname: []const u8, owner: []const u8) bool { + var kb: [64]u8 = undefined; + const key = std.fmt.bufPrint(&kb, prefix ++ "gamechars:{d}", .{gameid}) catch return false; + var mb: [96]u8 = undefined; + const member = std.fmt.bufPrint(&mb, "{s}/{s}", .{ account, charname }) catch return false; + var lb: [128]u8 = undefined; + const lockkey = std.fmt.bufPrint(&lb, prefix ++ "charlock:{s}/{s}", .{ account, charname }) catch return false; + // The lock is released only if this game still owns it — the same compare-and-swap every other + // release does. A lapsed lease may already have been taken by somebody else, and a blind DEL + // would free THEIR claim. + const script = + \\if redis.call('SREM', KEYS[1], ARGV[2]) == 0 then return 0 end + \\if redis.call('GET', KEYS[2]) == ARGV[1] then redis.call('DEL', KEYS[2]) end + \\return 1 + ; + const s = acquire(); + defer release(s); + var r: Reader = undefined; + const rep = command(s, &r, &.{ "EVAL", script, "2", key, lockkey, owner, member }) orelse return false; + return switch (rep) { + .int => |v| v == 1, + else => false, + }; +} + pub fn releaseGameCharByName(gameid: u32, charname: []const u8, owner: []const u8) bool { var kb: [64]u8 = undefined; const key = std.fmt.bufPrint(&kb, prefix ++ "gamechars:{d}", .{gameid}) catch return false; var sb: [64]u8 = undefined; const suffix = std.fmt.bufPrint(&sb, "/{s}", .{charname}) catch return false; + // Two passes, and the second only runs if the first found EXACTLY one match. + // + // Members are "account/charname" and the game server reports a departure by character name + // alone — it never carries the account. Character names are only unique per account on this + // realm, so a game can legitimately hold two members ending in "/Bob". Releasing the first the + // set happens to yield (SMEMBERS is unordered) frees a lock belonging to a player who is still + // in the world; another game then claims that character, both sessions write it, and whichever + // finishes second silently discards the other. That is a rollback with no failure anywhere. + // + // Ambiguity therefore releases NOTHING. The lock lingers until the game ends, where + // `releaseGameChars` frees everything the game held by gameid and needs no names at all — a + // late release is a small delay, and the alternative is somebody else's character. const script = + \\local hit, n = nil, 0 \\for _, m in ipairs(redis.call('SMEMBERS', KEYS[1])) do \\ if string.sub(m, -string.len(ARGV[3])) == ARGV[3] then - \\ local lk = ARGV[2] .. m - \\ if redis.call('GET', lk) == ARGV[1] then redis.call('DEL', lk) end - \\ redis.call('SREM', KEYS[1], m) - \\ return 1 + \\ hit = m + \\ n = n + 1 \\ end \\end - \\return 0 + \\if n ~= 1 then return 0 end + \\local lk = ARGV[2] .. hit + \\if redis.call('GET', lk) == ARGV[1] then redis.call('DEL', lk) end + \\redis.call('SREM', KEYS[1], hit) + \\return 1 ; const s = acquire(); defer release(s); diff --git a/tools/e2e/fakegs.zig b/tools/e2e/fakegs.zig index dc181304..52d59729 100644 --- a/tools/e2e/fakegs.zig +++ b/tools/e2e/fakegs.zig @@ -13,6 +13,15 @@ const rc = @import("realmclient.zig"); /// Where the harness's redis is. Set once by main before any FakeGS starts. pub var redis_port: u16 = 6399; +/// The four bytes CREATEGAMEREQ leads its body with (realm-proto's CreateGameReq): the kind +/// of game realmd made of the creating character's flags. +pub const CreateFlags = struct { + ladder: u8 = 0, + expansion: u8 = 0, + difficulty: u8 = 0, + hardcore: u8 = 0, +}; + pub const FakeGS = struct { gsid: u32 = 0xABCD, ip: [4]u8 = .{ 127, 0, 0, 1 }, @@ -25,7 +34,15 @@ pub const FakeGS = struct { registered: bool = false, creates: u32 = 0, + /// The flags of the LAST create request, so a scenario can assert on what its + /// character's status turned into on the wire. Meaningless while creates == 0. + create_flags: CreateFlags = .{}, joins: u32 = 0, + /// The character and account of the LAST join request. A real GS keeps exactly this pairing + /// and keys every save of that session by it — the account never reaches the engine any other + /// way — so a scenario asserting on saves has to read it from here, as the server does. + join_char: [16]u8 = @splat(0), + join_account: [32]u8 = @splat(0), stop_flag: bool = false, thread: ?std.Thread = null, _next: u32 = 0, @@ -97,6 +114,12 @@ pub const FakeGS = struct { var gid = self.gameid; if (typ == rc.GS_CREATEGAME) { self.creates += 1; + if (n >= 12) self.create_flags = .{ + .ladder = buf[8], + .expansion = buf[9], + .difficulty = buf[10], + .hardcore = buf[11], + }; if (self.refuse_create_with != 0) { result = self.refuse_create_with; gid = 0; @@ -110,6 +133,21 @@ pub const FakeGS = struct { } } else if (typ == rc.GS_JOINGAME) { self.joins += 1; + // gameid(4) token(4) charname\0 account\0 [guild\0] + if (n > 16) { + var off: usize = 16; + const cs = off; + while (off < n and buf[off] != 0) off += 1; + const cn = @min(off - cs, self.join_char.len - 1); + self.join_char = @splat(0); + @memcpy(self.join_char[0..cn], buf[cs..][0..cn]); + if (off < n) off += 1; + const as = off; + while (off < n and buf[off] != 0) off += 1; + const an = @min(off - as, self.join_account.len - 1); + self.join_account = @splat(0); + @memcpy(self.join_account[0..an], buf[as..][0..an]); + } } else continue; var reply: [16]u8 = undefined; diff --git a/tools/e2e/main.zig b/tools/e2e/main.zig index 9ea3b990..a8209f2b 100644 --- a/tools/e2e/main.zig +++ b/tools/e2e/main.zig @@ -571,6 +571,233 @@ fn scCreateJoinGame() Result { return .{ .name = name, .status = .pass, .msg = msg("create+join ok create-token={d} join-token={d} gs_ip=127.0.0.1 (creates={d} joins={d})", .{ cg.token, jg.token, gs.creates, gs.joins }) }; } +/// The store refuses a save built from bytes it has since moved past. +/// +/// This is the mechanism that makes a rollback impossible rather than merely unlikely. Every other +/// guard narrows the ways older bytes can reach the store — the seat table, the character lock, +/// the retry queue's age bound, refusing to delete a character that is in a game. This one closes +/// the question: even if every one of those failed at once, the store itself will not accept a +/// save whose version has been superseded. +fn scSaveFence() Result { + const name = "save_fence"; + const acct = "FenceAcct"; + const char = "Fenced"; + + var d2s: [0x40]u8 = undefined; + const v1 = minimalD2s(&d2s, char, 1, 10); + if ((rc.storePutChar(acct, char, v1) catch 1) != 0) return fail(name, "staging failed", .{}); + + const at_load = rc.storeCharVersion(acct, char) catch |e| return fail(name, "{s}", .{@errorName(e)}); + if (at_load == 0) return fail(name, "staged character has no version to fence on", .{}); + + // A save built from what we loaded is accepted, and moves the version on. + var b2: [0x40]u8 = undefined; + const v2 = minimalD2s(&b2, char, 1, 20); + const after = (rc.storePutCharFenced(acct, char, v2, at_load) catch |e| return fail(name, "{s}", .{@errorName(e)})) orelse + return fail(name, "a save at the version we loaded was refused", .{}); + if (after <= at_load) return fail(name, "version did not advance: {d} -> {d}", .{ at_load, after }); + + // Now the rollback, attempted directly: a second server still holding the OLD version tries to + // write the OLD bytes. This is what every bug in this area eventually reduces to, and it must + // be refused whatever led to it. + const stale = rc.storePutCharFenced(acct, char, v1, at_load) catch |e| return fail(name, "{s}", .{@errorName(e)}); + if (stale != null) return fail(name, "a stale save was ACCEPTED — the fence is not holding", .{}); + + // And the newer character is intact: refused, not partially applied. + var c = rc.RealmClient{}; + defer c.close(); + c.connectBnet() catch |e| return fail(name, "{s}", .{@errorName(e)}); + c.auth() catch |e| return fail(name, "{s}", .{@errorName(e)}); + c.login(acct) catch |e| return fail(name, "{s}", .{@errorName(e)}); + c.enterRealm() catch |e| return fail(name, "{s}", .{@errorName(e)}); + c.connectD2cs() catch |e| return fail(name, "{s}", .{@errorName(e)}); + if ((c.startup() catch 1) != 0) return fail(name, "d2cs startup failed", .{}); + + var entries: [64]rc.CharEntry = undefined; + var dst: [4096]u8 = undefined; + const cl = c.charList(&entries, &dst) catch |e| return fail(name, "{s}", .{@errorName(e)}); + for (entries[0..cl.count]) |e| { + if (!std.mem.eql(u8, e.name, char)) continue; + if (e.level != 20) return fail(name, "level={d} after a refused stale save, want 20", .{e.level}); + } + return .{ .name = name, .status = .pass, .msg = msg("v{d} -> v{d} accepted; the same save replayed at v{d} refused, character still level 20", .{ at_load, after, at_load }) }; +} + +/// A character name belongs to the realm, not to an account. +/// +/// Names used to be checked only within the creating account, so two players could both have a +/// "Bob" — and then every part of the system that identifies a character by name alone had two +/// answers: the game servers' seat tables, the save-retry queue, a departing player's seat +/// release, and the Mac engine's own `.d2s`. That is not a rollback, it is one player's +/// save filed under the other's account. +fn scRealmUniqueNames() Result { + const name = "realm_unique_names"; + const shared = "Contested"; + + var a = rc.RealmClient{}; + defer a.close(); + a.connectBnet() catch |e| return fail(name, "{s}", .{@errorName(e)}); + a.auth() catch |e| return fail(name, "{s}", .{@errorName(e)}); + a.login("NameOwner") catch |e| return fail(name, "{s}", .{@errorName(e)}); + a.enterRealm() catch |e| return fail(name, "{s}", .{@errorName(e)}); + a.connectD2cs() catch |e| return fail(name, "{s}", .{@errorName(e)}); + if ((a.startup() catch 1) != 0) return fail(name, "d2cs startup failed (A)", .{}); + const made = a.charCreateFresh(1, 0x20, shared) catch |e| return fail(name, "{s}", .{@errorName(e)}); + if (made != 0) return fail(name, "first create of '{s}' failed: {d}", .{ shared, made }); + + var b = rc.RealmClient{}; + defer b.close(); + b.connectBnet() catch |e| return fail(name, "{s}", .{@errorName(e)}); + b.auth() catch |e| return fail(name, "{s}", .{@errorName(e)}); + b.login("NameRival") catch |e| return fail(name, "{s}", .{@errorName(e)}); + b.enterRealm() catch |e| return fail(name, "{s}", .{@errorName(e)}); + b.connectD2cs() catch |e| return fail(name, "{s}", .{@errorName(e)}); + if ((b.startup() catch 1) != 0) return fail(name, "d2cs startup failed (B)", .{}); + const taken = b.charCreateFresh(1, 0x20, shared) catch |e| return fail(name, "{s}", .{@errorName(e)}); + if (taken == 0) return fail(name, "a second account created '{s}' — names are not realm-unique", .{shared}); + + // The rival must not have acquired anything. + var entries: [64]rc.CharEntry = undefined; + var dst: [4096]u8 = undefined; + const cl = b.charList(&entries, &dst) catch |e| return fail(name, "{s}", .{@errorName(e)}); + for (entries[0..cl.count]) |e| { + if (std.mem.eql(u8, e.name, shared)) return fail(name, "the refused character is listed for the second account", .{}); + } + return .{ .name = name, .status = .pass, .msg = msg("'{s}' claimed by one account; a second account refused (0x{x})", .{ shared, taken }) }; +} + +/// A character that is in a game cannot be deleted out from under it. +/// +/// The game server holds the character in memory and writes it back on its own timer, so a delete +/// accepted here does not end the session — it loses a race with it. The next save recreates the +/// character; and if the player made a NEW one with the same name in between, that stale save +/// lands on top of the new character instead. Both outcomes read to the player as a rollback. +fn scDeleteInGame() Result { + const name = "delete_in_game"; + const acct = "DelGuard"; + const char = "Busy"; + + var gs = FakeGS{ .gsid = 0xDE1E, .ip = .{ 127, 0, 0, 1 }, .maxgame = 100, .gameid = 0xDE1E }; + gs.start(2000) catch |e| return fail(name, "{s}", .{@errorName(e)}); + defer gs.stop(); + if (!gs.isRegistered()) return fail(name, "FakeGS did not publish itself", .{}); + + var c = rc.RealmClient{}; + defer c.close(); + c.connectBnet() catch |e| return fail(name, "{s}", .{@errorName(e)}); + c.auth() catch |e| return fail(name, "{s}", .{@errorName(e)}); + c.login(acct) catch |e| return fail(name, "{s}", .{@errorName(e)}); + c.enterRealm() catch |e| return fail(name, "{s}", .{@errorName(e)}); + c.connectD2cs() catch |e| return fail(name, "{s}", .{@errorName(e)}); + if ((c.startup() catch 1) != 0) return fail(name, "d2cs startup failed", .{}); + + if ((c.charCreateFresh(1, 0x20, char) catch 1) != 0) return fail(name, "could not create '{s}'", .{char}); + if ((c.charLogon(char) catch 1) != 0) return fail(name, "could not log on as '{s}'", .{char}); + + // Deletable before it is anywhere. Asserted first so a later refusal cannot be mistaken for + // delete simply being broken. + const cg = c.createGame("delgame", "d") catch |e| return fail(name, "{s}", .{@errorName(e)}); + if (cg.result != 0) return fail(name, "create result={d}", .{cg.result}); + const jg = c.joinGame("delgame") catch |e| return fail(name, "{s}", .{@errorName(e)}); + if (jg.result != 0) return fail(name, "join result={d}", .{jg.result}); + + // Now it is in a game, and the realm holds its seat. + const refused = c.charDelete(char) catch |e| return fail(name, "{s}", .{@errorName(e)}); + if (refused == 0) return fail(name, "delete of an in-game character SUCCEEDED, want refusal", .{}); + + // And it is still there. + var entries: [64]rc.CharEntry = undefined; + var dst: [4096]u8 = undefined; + const after = c.charList(&entries, &dst) catch |e| return fail(name, "{s}", .{@errorName(e)}); + var present = false; + for (entries[0..after.count]) |e| { + if (std.mem.eql(u8, e.name, char)) present = true; + } + if (!present) return fail(name, "'{s}' vanished from the list after a refused delete", .{char}); + + return .{ .name = name, .status = .pass, .msg = msg("delete of an in-game character refused (0x{x}); '{s}' still listed", .{ refused, char }) }; +} + +/// A save is only durable if it is written under the right ACCOUNT — and the account reaches the +/// game server in one place only: the JOINGAME the realm dispatches. Get that pairing wrong and +/// nothing fails: the save lands at an address no login path reads, the server logs a success, +/// and the player comes back rolled back to their previous session. `save_durability` below +/// covers the other half — that a save which reached the store survives the flush; this one is +/// about it reaching the right place to begin with. That silence is why it is a scenario. +fn scSaveAccountKey() Result { + const name = "save_account_key"; + const acct = "SaveAcct"; + const char = "Persist"; + + var gs = FakeGS{ .gsid = 0x5A4E, .ip = .{ 127, 0, 0, 1 }, .maxgame = 100, .gameid = 0x5A4E }; + gs.start(2000) catch |e| return fail(name, "{s}", .{@errorName(e)}); + defer gs.stop(); + if (!gs.isRegistered()) return fail(name, "FakeGS did not publish itself", .{}); + + var c = rc.RealmClient{}; + defer c.close(); + c.connectBnet() catch |e| return fail(name, "{s}", .{@errorName(e)}); + c.auth() catch |e| return fail(name, "{s}", .{@errorName(e)}); + c.login(acct) catch |e| return fail(name, "{s}", .{@errorName(e)}); + c.enterRealm() catch |e| return fail(name, "{s}", .{@errorName(e)}); + c.connectD2cs() catch |e| return fail(name, "{s}", .{@errorName(e)}); + if ((c.startup() catch 1) != 0) return fail(name, "d2cs startup failed", .{}); + + if ((c.charCreateFresh(1, 0x20, char) catch 1) != 0) return fail(name, "could not create '{s}'", .{char}); + if ((c.charLogon(char) catch 1) != 0) return fail(name, "could not log on as '{s}'", .{char}); + + const cg = c.createGame("savegame", "d") catch |e| return fail(name, "{s}", .{@errorName(e)}); + if (cg.result != 0) return fail(name, "create result={d}", .{cg.result}); + const jg = c.joinGame("savegame") catch |e| return fail(name, "{s}", .{@errorName(e)}); + if (jg.result != 0) return fail(name, "join result={d}", .{jg.result}); + + // What the realm told the server this character belongs to. Everything below keys off this + // rather than off `acct`, because this is the only value a real server ever has. + const told_char = std.mem.sliceTo(&gs.join_char, 0); + const told_acct = std.mem.sliceTo(&gs.join_account, 0); + if (told_char.len == 0 or told_acct.len == 0) + return fail(name, "JOINGAME carried char='{s}' account='{s}' — the server cannot key a save without both", .{ told_char, told_acct }); + if (!std.ascii.eqlIgnoreCase(told_char, char)) + return fail(name, "JOINGAME named char '{s}', want '{s}'", .{ told_char, char }); + if (!std.ascii.eqlIgnoreCase(told_acct, acct)) + return fail(name, "JOINGAME named account '{s}', want '{s}'", .{ told_acct, acct }); + + // The session happens. The server saves under the account it was told. + var d2s: [0x40]u8 = undefined; + const played = minimalD2s(&d2s, char, 1, 42); + const sr = rc.storePutChar(told_acct, char, played) catch |e| return fail(name, "saving under '{s}': {s}", .{ told_acct, @errorName(e) }); + if (sr != 0) return fail(name, "save under '{s}' failed: result={d}", .{ told_acct, sr }); + + // Logging back in must show the session that just happened, not the one before it. + var entries: [64]rc.CharEntry = undefined; + var dst: [4096]u8 = undefined; + const after = c.charList(&entries, &dst) catch |e| return fail(name, "{s}", .{@errorName(e)}); + var lvl: ?u32 = null; + for (entries[0..after.count]) |e| { + if (std.mem.eql(u8, e.name, char)) lvl = e.level; + } + const played_level = lvl orelse return fail(name, "'{s}' vanished from the character list after a save", .{char}); + if (played_level != 42) return fail(name, "level={d} after the save, want 42 — the save did not come back", .{played_level}); + + // And the shape of the rollback, asserted directly. A save keyed by the CHARACTER's name + // instead of the account — what the server wrote whenever it had lost the join's account — + // must leave the real character untouched. It is a well-formed save at an address nothing + // reads, which is exactly why the bug was invisible from the server's side. + const stray = minimalD2s(&d2s, char, 1, 99); + _ = rc.storePutChar(char, char, stray) catch |e| return fail(name, "{s}", .{@errorName(e)}); + + var entries2: [64]rc.CharEntry = undefined; + var dst2: [4096]u8 = undefined; + const final = c.charList(&entries2, &dst2) catch |e| return fail(name, "{s}", .{@errorName(e)}); + for (entries2[0..final.count]) |e| { + if (!std.mem.eql(u8, e.name, char)) continue; + if (e.level != 42) + return fail(name, "level={d} after a save keyed by the character name, want 42", .{e.level}); + } + + return .{ .name = name, .status = .pass, .msg = msg("JOINGAME carried {s}/{s}; a save under that account came back (lvl 1 -> 42) and one under the char name did not", .{ told_acct, told_char }) }; +} + /// The join screen's PLAYERS column, and the description beside it. realmd only ever sees /// joins pass through it, so a count it maintained alone could only ever climb; the number /// that matters is the one the hosting GS reports. Asserting a DROP is the whole point. @@ -879,6 +1106,85 @@ fn scDifficultyGate() Result { return .{ .name = name, .status = .pass, .msg = msg("Nightmare gated at progression 5, Hell at 10; Normal open (engine thresholds)", .{}) }; } +/// Make a character with `status`, log it on, and have it create a game — then hand back the +/// flag bytes the request carried to the GS. The whole point is the trip: the .d2s status byte +/// the client asked for has to survive into CREATEGAMEREQ, and only the server sees both ends. +fn createGameFlags(gs: *FakeGS, acct: []const u8, char: []const u8, status: u8, game: []const u8) !fakegs.CreateFlags { + var c = rc.RealmClient{}; + defer c.close(); + try c.connectBnet(); + try c.auth(); + try c.login(acct); + try c.enterRealm(); + try c.connectD2cs(); + if ((try c.startup()) != 0) return error.StartupFailed; + if ((try c.charCreateFresh(1, status, char)) != 0) return error.CharCreateFailed; // 1 = Sorceress + if ((try c.charLogon(char)) != 0) return error.CharLogonFailed; + const cg = try c.createGame(game, "d"); + if (cg.result != 0) return error.CreateGameRefused; + if (gs.creates == 0) return error.NoCreateReachedGs; + return gs.create_flags; +} + +/// A classic character's game must reach the GS as classic. Getting this wrong makes the +/// engine build an expansion world for a client that has no expansion data. +fn scClassicGameFlags() Result { + const name = "classic_game_flags"; + var gs = FakeGS{ .gsid = 0xC1A5, .ip = .{ 127, 0, 0, 1 }, .maxgame = 100, .gameid = 7101 }; + gs.start(2000) catch |e| return fail(name, "{s}", .{@errorName(e)}); + defer gs.stop(); + if (!gs.isRegistered()) return fail(name, "FakeGS did not register", .{}); + + const f = createGameFlags(&gs, "FlagAcct", "FlagClassic", 0, "classicflags") catch |e| return fail(name, "{s}", .{@errorName(e)}); + if (f.expansion != 0) return fail(name, "expansion={d}, want 0 for a classic character", .{f.expansion}); + return .{ .name = name, .status = .pass, .msg = msg("classic creator -> expansion=0 (ladder={d} hardcore={d} diff={d})", .{ f.ladder, f.hardcore, f.difficulty }) }; +} + +/// Hardcore is the one flag a mistake cannot be undone by: a softcore world for a hardcore +/// character silently drops permadeath. +fn scHardcoreGameFlags() Result { + const name = "hardcore_game_flags"; + var gs = FakeGS{ .gsid = 0xC0DE, .ip = .{ 127, 0, 0, 1 }, .maxgame = 100, .gameid = 7102 }; + gs.start(2000) catch |e| return fail(name, "{s}", .{@errorName(e)}); + defer gs.stop(); + if (!gs.isRegistered()) return fail(name, "FakeGS did not register", .{}); + + const f = createGameFlags(&gs, "FlagAcct", "FlagHardcore", 0x24, "hcflags") catch |e| return fail(name, "{s}", .{@errorName(e)}); + if (f.hardcore != 1) return fail(name, "hardcore={d}, want 1 for a hardcore character", .{f.hardcore}); + if (f.expansion != 1) return fail(name, "expansion={d}, want 1 (the character has 0x20 too)", .{f.expansion}); + return .{ .name = name, .status = .pass, .msg = msg("hardcore creator -> hardcore=1 expansion=1 (ladder={d})", .{f.ladder}) }; +} + +/// The ladder bit used to be dropped between the request and the engine's game flags, so +/// every ladder character was refused entry to the game it had just made. +fn scLadderGameFlags() Result { + const name = "ladder_game_flags"; + var gs = FakeGS{ .gsid = 0x1ADD, .ip = .{ 127, 0, 0, 1 }, .maxgame = 100, .gameid = 7103 }; + gs.start(2000) catch |e| return fail(name, "{s}", .{@errorName(e)}); + defer gs.stop(); + if (!gs.isRegistered()) return fail(name, "FakeGS did not register", .{}); + + const f = createGameFlags(&gs, "FlagAcct", "FlagLadder", 0x60, "ladderflags") catch |e| return fail(name, "{s}", .{@errorName(e)}); + if (f.ladder != 1) return fail(name, "ladder={d}, want 1 for a ladder character", .{f.ladder}); + if (f.expansion != 1) return fail(name, "expansion={d}, want 1 (the character has 0x20 too)", .{f.expansion}); + return .{ .name = name, .status = .pass, .msg = msg("ladder creator -> ladder=1 expansion=1 (hardcore={d})", .{f.hardcore}) }; +} + +/// And the other edge: a non-ladder character must not be handed a ladder game, which is what +/// a flag hardcoded on rather than dropped would do. +fn scNonLadderGameFlags() Result { + const name = "non_ladder_game_flags"; + var gs = FakeGS{ .gsid = 0x0ADD, .ip = .{ 127, 0, 0, 1 }, .maxgame = 100, .gameid = 7104 }; + gs.start(2000) catch |e| return fail(name, "{s}", .{@errorName(e)}); + defer gs.stop(); + if (!gs.isRegistered()) return fail(name, "FakeGS did not register", .{}); + + const f = createGameFlags(&gs, "FlagAcct", "FlagSoftie", 0x20, "noladderflags") catch |e| return fail(name, "{s}", .{@errorName(e)}); + if (f.ladder != 0) return fail(name, "ladder={d}, want 0 for a non-ladder character", .{f.ladder}); + if (f.hardcore != 0) return fail(name, "hardcore={d}, want 0 for a softcore character", .{f.hardcore}); + return .{ .name = name, .status = .pass, .msg = msg("non-ladder creator -> ladder=0 hardcore=0 (expansion={d})", .{f.expansion}) }; +} + fn scGetFileTime() Result { const name = "get_file_time"; const data_dir = envOr("REALMD_DATA_DIR", "/tmp/e2e-realmd"); @@ -1563,15 +1869,36 @@ fn startStores() void { _ = system("docker exec e2e-redis redis-cli FLUSHALL >/dev/null 2>&1"); // clean slate _ = setenv("REALMD_REDIS_ADDR", "127.0.0.1:6399", 1); _ = setenv("REALMD_PG_DSN", "postgres://realmd:realmd@127.0.0.1:55499/realmd", 1); - // The port being open is not the same as the server accepting connections; postgres listens - // briefly before it will talk. Wait for a query to actually succeed rather than for realmd to - // discover it the hard way. + // The port being open is not the same as the server accepting connections, and neither is + // `pg_isready`. + // + // On its FIRST boot the postgres image runs initdb, which starts a temporary server to create + // the database and then shuts it down and restarts the real one. That temporary server listens + // on a Unix socket only — so `pg_isready`, which prefers the socket, reports "accepting + // connections" against a server that is about to disappear and was never reachable over TCP. + // Meanwhile docker has already published the port, so a plain port check passes too. realmd + // then connects into the gap and dies with `EndOfStream`, and the whole suite fails for + // reasons that have nothing to do with what it was testing. That was roughly one run in two. + // + // `-h 127.0.0.1` is what tells the two apart: it forces TCP, which only the real server + // offers. A query, not a ping, because a server can accept a connection while still recovering. var waited: u32 = 0; - while (waited < 30_000) : (waited += 250) { - if (system("docker exec e2e-postgres pg_isready -q -U realmd >/dev/null 2>&1") == 0) break; + var pg_ready = false; + while (waited < 60_000) : (waited += 250) { + if (system("docker exec e2e-postgres psql -U realmd -h 127.0.0.1 -d realmd -c 'select 1' >/dev/null 2>&1") == 0) { + pg_ready = true; + break; + } _ = net.usleep(250_000); } - std.debug.print("started e2e-redis :{d} and e2e-postgres :{d}\n", .{ REDIS_HOST_PORT, PG_HOST_PORT }); + if (!pg_ready) { + // Said plainly and fatally. Carrying on gets realmd killed by a connection error further + // down, which reads as a realm bug rather than a container that never came up. + std.debug.print("ERROR: postgres never accepted a TCP query on :{d} within 60s\n", .{PG_HOST_PORT}); + stopStores(); + std.process.exit(2); + } + std.debug.print("started e2e-redis :{d} and e2e-postgres :{d} (postgres answered a query)\n", .{ REDIS_HOST_PORT, PG_HOST_PORT }); } fn stopStores() void { @@ -2476,6 +2803,10 @@ pub fn main() !void { scMcpOn6112(), scCharListStatstring(), scCreateJoinGame(), + scSaveAccountKey(), + scDeleteInGame(), + scSaveFence(), + scRealmUniqueNames(), scGamePopulation(), scJoinErrors(), scGameInfo(), @@ -2501,6 +2832,10 @@ pub fn main() !void { scNameResolution(), scLeaveChannel(), scDifficultyGate(), + scClassicGameFlags(), + scHardcoreGameFlags(), + scLadderGameFlags(), + scNonLadderGameFlags(), scGetFileTime(), scBannerAd(), scSaveDurability(), diff --git a/tools/e2e/realmclient.zig b/tools/e2e/realmclient.zig index 11ce3299..db41ca64 100644 --- a/tools/e2e/realmclient.zig +++ b/tools/e2e/realmclient.zig @@ -864,12 +864,68 @@ pub const AdInfo = struct { /// them. /// /// Returns 0 on success, to keep the shape the callers already check. +/// The store's current version for a character, 0 if it has none. +pub fn storeCharVersion(account: []const u8, charname: []const u8) !u64 { + var c = try gsstore.Client.connect(fakegs.redis_port); + defer c.close(); + var vb: [192]u8 = undefined; + const verkey = std.fmt.bufPrint(&vb, "realmd:charver:{s}/{s}", .{ account, charname }) catch return error.NameTooLong; + return switch (try c.cmd(&.{ "GET", verkey })) { + .bulk => |b| if (b) |v| std.fmt.parseInt(u64, v, 10) catch 0 else 0, + else => 0, + }; +} + +/// A fenced save, exactly as a game server performs one: the store takes the bytes only if nothing +/// has written this character since version `expect`. The script is the same one +/// `gs_store.putCharFenced` runs, kept here in the harness so the rule can be exercised against a +/// real redis without a game server. +/// +/// Returns the new version, or null when the store refused it as stale. +pub fn storePutCharFenced(account: []const u8, charname: []const u8, d2s: []const u8, expect: u64) !?u64 { + var c = try gsstore.Client.connect(fakegs.redis_port); + defer c.close(); + var kb: [192]u8 = undefined; + const key = std.fmt.bufPrint(&kb, "realmd:char:{s}:{s}", .{ account, charname }) catch return error.NameTooLong; + var vb: [192]u8 = undefined; + const verkey = std.fmt.bufPrint(&vb, "realmd:charver:{s}/{s}", .{ account, charname }) catch return error.NameTooLong; + var sb: [192]u8 = undefined; + const setkey = std.fmt.bufPrint(&sb, "realmd:chars:{s}", .{account}) catch return error.NameTooLong; + var mb: [192]u8 = undefined; + const member = std.fmt.bufPrint(&mb, "{s}/{s}", .{ account, charname }) catch return error.NameTooLong; + var eb: [24]u8 = undefined; + const expect_s = std.fmt.bufPrint(&eb, "{d}", .{expect}) catch return error.NameTooLong; + const script = + \\local cur = tonumber(redis.call('GET', KEYS[2]) or '0') + \\if cur ~= 0 and cur ~= tonumber(ARGV[1]) then return -(cur + 1) end + \\redis.call('SET', KEYS[1], ARGV[4]) + \\redis.call('SADD', KEYS[3], ARGV[2]) + \\redis.call('SADD', KEYS[4], ARGV[3]) + \\return redis.call('INCR', KEYS[2]) + ; + const rep = try c.cmdBig(&.{ + "EVAL", script, "4", key, + verkey, setkey, "realmd:dirty", + expect_s, charname, member, + }, d2s); + return switch (rep) { + .int => |v| if (v > 0) @as(u64, @intCast(v)) else null, + else => error.UnexpectedReply, + }; +} + pub fn storePutChar(account: []const u8, charname: []const u8, d2s: []const u8) !u32 { var c = try gsstore.Client.connect(fakegs.redis_port); defer c.close(); var kb: [192]u8 = undefined; const key = std.fmt.bufPrint(&kb, "realmd:char:{s}:{s}", .{ account, charname }) catch return error.NameTooLong; _ = try c.cmdBig(&.{ "SET", key }, d2s); + // The account's character set, exactly as the game server's own `putChar` writes it: that + // set is what CHARLIST reads for anything postgres has not caught up with yet, so a harness + // that skipped it would be testing a save path the server does not have. + var sb: [192]u8 = undefined; + const setkey = std.fmt.bufPrint(&sb, "realmd:chars:{s}", .{account}) catch return error.NameTooLong; + _ = try c.cmd(&.{ "SADD", setkey, charname }); var vb: [192]u8 = undefined; _ = try c.cmd(&.{ "INCR", std.fmt.bufPrint(&vb, "realmd:charver:{s}/{s}", .{ account, charname }) catch return error.NameTooLong }); var mb: [128]u8 = undefined; From e3b0635fc50788b7e0c0ffa07029678a774bf6dc Mon Sep 17 00:00:00 2001 From: jaenster Date: Mon, 7 Sep 2026 21:57:31 +0200 Subject: [PATCH 3/3] e2e-engines: reset the realm-wide name claims too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The harness clears its characters from both stores between engines, but a name claim outlives a row deleted behind the realm's back — leaving a name owned by a character that no longer exists. Harmless today, because every run uses the same account per engine and an account may re-claim its own name. It stops being harmless the moment the account naming changes, and the symptom would be a create refused as "name taken" with nothing holding it. --- tools/e2e-engines.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tools/e2e-engines.sh b/tools/e2e-engines.sh index f3875966..9c1d0bcb 100755 --- a/tools/e2e-engines.sh +++ b/tools/e2e-engines.sh @@ -109,8 +109,15 @@ clean_realm() { for pat in 'realmd:char:e2e*' 'realmd:chars:e2e*' 'realmd:charver:e2e*'; do for k in $(redis --scan --pattern "$pat"); do redis DEL "$k" >/dev/null; done done + # The realm-wide name claim goes with the character. A name is claimed for as long as a + # character holds it, and the claim outlives a row deleted behind the realm's back — so a + # harness that clears `chars` but not this leaves the name owned by a character that no longer + # exists. It happens to be harmless while every run uses the same account per engine, since an + # account may re-claim its own name; it stops being harmless the moment the accounts change. docker exec d2gs-dev-postgres psql -qtAX -U realmd -d realmd \ -c "delete from chars where account like 'e2e%'" >/dev/null 2>&1 || true + docker exec d2gs-dev-postgres psql -qtAX -U realmd -d realmd \ + -c "delete from charnames where account like 'e2e%'" >/dev/null 2>&1 || true redis DEL realmd:gs >/dev/null }