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
27 changes: 27 additions & 0 deletions apps/realmd/admin.zig
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
//! POST /admin/accounts {"name","password"} -> create account
//! POST /admin/games/close {"name"} (or ?name=) -> expire a game
//! POST /admin/chars/copy {"src_account","src_char","dst_char"[,"dst_account"]} -> clone char
//! POST /admin/chars/import {"account","char","d2s":"<base64>"} -> put a save on an account
const std = @import("std");
const net = @import("realm_infra").net;
const fleet = @import("fleet.zig");
Expand Down Expand Up @@ -344,6 +345,9 @@ pub fn handle(fd: net.Socket, method: []const u8, path: []const u8, req: []const
} else if (std.mem.eql(u8, p, "/admin/chars/delete")) {
if (!is_post) return respond(fd, method_not_allowed, "{\"error\":\"method not allowed\"}");
return charsDelete(fd, req);
} else if (std.mem.eql(u8, p, "/admin/chars/import")) {
if (!is_post) return respond(fd, method_not_allowed, "{\"error\":\"method not allowed\"}");
return charsImport(fd, req);
} else if (std.mem.eql(u8, p, "/admin/chars/copy")) {
if (!is_post) return respond(fd, method_not_allowed, "{\"error\":\"method not allowed\"}");
return charsCopy(fd, req);
Expand Down Expand Up @@ -498,6 +502,29 @@ fn charsCopy(fd: net.Socket, req: []const u8) void {
}
}

// POST /admin/chars/import {"account","char","d2s":"<base64>"} — put a save file on an account.
//
// Base64 because this is a JSON API and a .d2s is binary; standard alphabet with padding, which is
// what every tool that will produce one emits. The realm rewrites the name and repairs the checksum
// on the way in, so a save exported under one name lands correctly under another.
fn charsImport(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\"}");
const encoded = jsonStr(body, "d2s") orelse return respond(fd, bad_request, "{\"error\":\"missing d2s\"}");

var raw: [store.max_d2s]u8 = undefined;
const dec = std.base64.standard.Decoder;
const n = dec.calcSizeForSlice(encoded) catch return respond(fd, bad_request, "{\"error\":\"d2s is not base64\"}");
if (n > raw.len) return respond(fd, bad_request, "{\"error\":\"d2s too large\"}");
dec.decode(raw[0..n], encoded) catch return respond(fd, bad_request, "{\"error\":\"d2s is not base64\"}");

if (!store.importChar(account, char, raw[0..n])) {
return respond(fd, conflict, "{\"error\":\"import failed (bad save, invalid name, or character exists)\"}");
}
respond(fd, ok, "{\"imported\":true}");
}

// POST /admin/chars/delete {"account","char"} — remove one character. The companion to
// chars/copy: an operator who can clone a character should be able to undo it. Idempotent, so
// a repeat is not an error.
Expand Down
50 changes: 43 additions & 7 deletions apps/realmd/d2cs.zig
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const log = @import("realm_infra").log;
const proto = @import("proto.zig");
const state = @import("state.zig");
const store = @import("store.zig");
const version = @import("version.zig");
const d2s = @import("d2s.zig");
const fleet = @import("fleet.zig");
const guilds = @import("guilds.zig");
Expand Down Expand Up @@ -382,12 +383,37 @@ fn onCharList(c: *DConn, tag: []const u8, body: []const u8) void {

w.putU32(0xFFFF_FFFF); // expiration — far future so it's NOT "expired"
w.putStr(names[i].slice()); // character name
writeStatString(&w, class, level, status, progression, @intCast(total), app1, app2); // CharSel.cpp layout
const era = eraCode(store.charVersion(c.accountName(), names[i].slice()).slice());
writeStatString(&w, class, level, status, progression, @intCast(total), app1, app2, era); // CharSel.cpp layout
w.putU8(0); // statstring C-string terminator
}
finish(c, &w);
}

/// The two characters that stand for an engine in a character's guild tag.
///
/// The tag is the only field of the statstring the game hands straight back to the player, so it
/// is where a realm serving several engines can say which one a character is — and every client
/// shows it, not only one that was built knowing about it. It renders as " {14}" after the name.
///
/// TWO characters and never three. `D2CharSelStrc.szGuildTag` is `char[3]` filled by a strncpy of
/// three, so a three-character tag arrives with no terminator and the client's `" {%s}"` runs on
/// into the difficulty byte at 0x32D. Two leaves the statstring's own terminator to land in the
/// third.
///
/// Two is enough because that is exactly where the tags differ: "1.06b", "1.09d", "1.14d" all
/// carry their era in the two characters after the dot. Anything shaped differently gets no tag
/// rather than a wrong one.
fn eraCode(tag: []const u8) []const u8 {
if (tag.len < 4) return "";
if (tag[0] != '1' or tag[1] != '.') return "";
const code = tag[2..4];
for (code) |ch| {
if (ch < '0' or ch > '9') return "";
}
return code;
}

// 14-bit-encoded int: 7 bits/byte, high bit always set so the value never
// produces a 0x00 byte (the statstring is sent as a C-string).
fn enc14(w: *proto.Writer, v: u32) void {
Expand All @@ -413,7 +439,7 @@ fn putEquipSlot(w: *proto.Writer, app: []const u8) void {
}
}

fn writeStatString(w: *proto.Writer, class: u8, level: u8, status: u8, progression: u8, realm_count: u32, app1: []const u8, app2: []const u8) void {
fn writeStatString(w: *proto.Writer, class: u8, level: u8, status: u8, progression: u8, realm_count: u32, app1: []const u8, app2: []const u8, era: []const u8) void {
enc14(w, realm_count); // realm char count (CharSel: nRealmCharCount)
putEquipSlot(w, app1); // equip slot 1: body-component graphic codes (.d2s pAppearance1)
w.putU8(class + 1); // class (CharSel subtracts CLASS_SORCERESS=1)
Expand All @@ -430,9 +456,11 @@ fn writeStatString(w: *proto.Writer, class: u8, level: u8, status: u8, progressi
w.putU8(0xFF); // act (0xFF -> 0)
w.putU8(0xFF); // field_0x32f
w.putU8(0xFF); // field_0x330
// No guild tag: the statstring's trailing NUL (added by the caller) lands on the
// guild-tag slot, so CharSel's strncpy reads an empty tag. Emitting 0xFF bytes here
// instead made it append " {ÿÿÿ}" garbage to the char name (looked like "no name").
// The guild tag, which on this realm is the character's engine. Two characters, so the
// caller's trailing NUL lands in the third and terminates it; a realm with nothing to say
// writes none and the NUL lands on the first, which is the empty tag the game expects.
// Emitting 0xFF bytes here instead made it append " {ÿÿÿ}" to the character's name.
for (era) |ch| w.putU8(ch);
}

// MCP_CHARLOGON (0x07) replies. The client stores the result dword as the D2GS join result and
Expand Down Expand Up @@ -490,7 +518,12 @@ fn onCharCreate(c: *DConn, tag: []const u8, body: []const u8) void {
// checkboxes set: expansion 0x20, hardcore 0x04, ladder 0x40.
var r = proto.Reader.init(body);
const class: u8 = @intCast(r.getU32() & 0xff);
const status_flags: u8 = @intCast(r.getU16() & 0x6C); // hardcore|died|expansion|ladder
const status_word = r.getU16();
const status_flags: u8 = @intCast(status_word & 0x6C); // hardcore|died|expansion|ladder
// The HIGH byte is not the game's. A stock client sends zero and the character takes the
// engine of the client that made it; a launcher that lets the player pick one sends its two
// digits, because that choice is made on the creation screen and not at logon.
const asked_era: u8 = @intCast(status_word >> 8);
const name = r.getStr();
const acct = c.accountName();

Expand Down Expand Up @@ -527,7 +560,10 @@ fn onCharCreate(c: *DConn, tag: []const u8, body: []const u8) void {
// Which engine this character belongs to, decided once and never again. An extension may say
// otherwise; the realm's own answer is the engine of the client that made it, which is empty
// on a realm that has not mapped its clients and means the character is unconstrained.
const char_version = hook.charVersion(acct, name, c.clientVersion()) orelse c.clientVersion();
// Order: what the request asked for, then what an extension says, then what the client is.
// The request wins because it is the only one of the three that knows what the player picked.
const asked = version.byEraCode(asked_era);
const char_version = asked orelse hook.charVersion(acct, name, c.clientVersion()) orelse c.clientVersion();
if (char_version.len != 0) _ = store.setCharVersion(acct, name, char_version);
log.line(tag, "char create '{s}' class={d} engine={s} (account={s}) -> created", .{ name, class, char_version, acct });
w.putU32(0); // success
Expand Down
25 changes: 24 additions & 1 deletion apps/realmd/store.zig
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ pub fn setUserData(account: []const u8, key: []const u8, value: []const u8) bool

/// Largest .d2s we will clone. A real 1.14d save is a few KB (a full char with stash is
/// well under this); refusing larger avoids a silently-truncated, corrupt copy.
const max_d2s = 32 * 1024;
pub const max_d2s = 32 * 1024;

/// Clone a character to a new name (and optionally a different account): read the source
/// save, rewrite its embedded name + checksum, and persist it at the destination. Works on
Expand All @@ -279,6 +279,29 @@ pub fn copyChar(src_account: []const u8, src_char: []const u8, dst_account: []co
return saveCharD2s(dst_account, dst_char, buf[0..n]);
}

/// Put a save file on an account under `name`.
///
/// The same rewrite `copyChar` does, for the same reason: the .d2s carries its own name and its own
/// checksum, and a client refuses one whose name does not match the character it asked for. So the
/// name is written in and the checksum repaired rather than trusting whatever the file arrived with.
///
/// 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 (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

var probe: [16]u8 = undefined;
if (getCharD2s(account, name, &probe) != 0) return false;

var buf: [max_d2s]u8 = undefined;
@memcpy(buf[0..bytes.len], bytes);
if (!d2s.setName(buf[0..bytes.len], name)) return false;
d2s.fixChecksum(buf[0..bytes.len]);
return saveCharD2s(account, name, buf[0..bytes.len]);
}

/// Result of a classic -> expansion conversion.
pub const UpgradeResult = enum { upgraded, already_expansion, no_such_char, failed };

Expand Down
18 changes: 18 additions & 0 deletions apps/realmd/version.zig
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,24 @@ fn parseWhat(what: []const u8) ?u32 {
///
/// The dword is asked first because it names an exact patch; the version byte only names a family
/// and several patches share one, so it is the fallback rather than the answer.
/// The engine whose tag carries these two digits, or null.
///
/// A character's engine is chosen on the creation screen, after the logon that would otherwise
/// have decided it, so MCP_CHARCREATE carries it in the high byte of its status word — as the
/// era's own two digits, 6 or 9 or 14. Matching them against the tags this realm is CONFIGURED
/// with rather than against a table of our own means a realm can only ever stamp a character with
/// an engine it actually serves.
pub fn byEraCode(code: u8) ?[]const u8 {
if (code == 0) return null;
var buf: [2]u8 = .{ '0' + @as(u8, @intCast(code / 10)), '0' + @as(u8, @intCast(code % 10)) };
for (configured[0..configured_n]) |e| {
if (e.tag.len < 4) continue;
if (e.tag[0] != '1' or e.tag[1] != '.') continue;
if (std.mem.eql(u8, e.tag[2..4], &buf)) return e.tag;
}
return null;
}

pub fn resolve(exe_version: u32, verbyte: u8) ?[]const u8 {
for (configured[0..configured_n]) |e| {
if (e.exe_version != 0 and e.exe_version == exe_version) return e.tag;
Expand Down
4 changes: 2 additions & 2 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
// every image build since the path landed failed on it. A pin also makes the version a
// deliberate commit here rather than whatever happens to be checked out next door.
.libd2 = .{
.url = "git+https://github.com/jaenster/libd2#fa2be8d7077538a3e53347d210144df3e2169432",
.hash = "libd2-0.6.2-wboU7a5MpgEi_0r6Oy7SUCdrpCcpsZwW1oX3y-AMxOPF",
.url = "git+https://github.com/jaenster/libd2#db503138e87e210029849a4b5014158ae006d4f8",
.hash = "libd2-0.6.5-wboU7c0iqQEB0zqTibDpAk-6dMhORMPb6HyvCfAglS5g",
},
// The real GS-join + world-decode leg, for tools/stress-e2e: d2-realm and d2-session are
// published from there specifically so a second Zig program can drive real games without
Expand Down
Loading