diff --git a/apps/realmd/admin.zig b/apps/realmd/admin.zig index 9e9f2ee..eed2eb9 100644 --- a/apps/realmd/admin.zig +++ b/apps/realmd/admin.zig @@ -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":""} -> put a save on an account const std = @import("std"); const net = @import("realm_infra").net; const fleet = @import("fleet.zig"); @@ -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); @@ -498,6 +502,29 @@ fn charsCopy(fd: net.Socket, req: []const u8) void { } } +// POST /admin/chars/import {"account","char","d2s":""} — 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. diff --git a/apps/realmd/d2cs.zig b/apps/realmd/d2cs.zig index 4f2e217..4c5563b 100644 --- a/apps/realmd/d2cs.zig +++ b/apps/realmd/d2cs.zig @@ -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"); @@ -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 { @@ -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) @@ -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 @@ -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(); @@ -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 diff --git a/apps/realmd/store.zig b/apps/realmd/store.zig index 4e6abac..fff1e3f 100644 --- a/apps/realmd/store.zig +++ b/apps/realmd/store.zig @@ -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 @@ -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 }; diff --git a/apps/realmd/version.zig b/apps/realmd/version.zig index 3358883..4f96efc 100644 --- a/apps/realmd/version.zig +++ b/apps/realmd/version.zig @@ -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; diff --git a/build.zig.zon b/build.zig.zon index 6e9949e..07d782d 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -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