Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 56 additions & 16 deletions apps/d2gs-native/chardb.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
// `<save path><charname>.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
// `<save path><charname>.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
Expand Down Expand Up @@ -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
Expand Down
93 changes: 87 additions & 6 deletions apps/d2gs-native/realm.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 `<save path><charname>.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 {
Expand Down Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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));
}
Loading
Loading