From ee7bd6b4bc502ef0f3e9f1722f3e4b4dcd3d3019 Mon Sep 17 00:00:00 2001 From: jaenster Date: Sun, 23 Aug 2026 10:39:08 +0200 Subject: [PATCH 1/3] realmd: a character's engine, in its guild tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A realm that hosts several engines has to say which one a character belongs to. CHARLOGON already refuses a cross-era one — the save format and the wire framing both moved between eras — and a player deserves to see that before they pick, not after a popup. The guild tag is the one field of the statstring the game hands straight back to the player, and it is empty on a realm with no guilds. So the engine goes there and EVERY client shows it, a stock 1.14d as well as our own launcher: it renders as " {14}" after the name. An op of our own would only ever have reached a client written for it. TWO characters and never three. 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 caller's own terminator to land in the third. Two is also exactly where the eras differ, so nothing is lost. --- apps/realmd/d2cs.zig | 37 ++++++++++++++++++++++++++++++++----- build.zig.zon | 4 ++-- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/apps/realmd/d2cs.zig b/apps/realmd/d2cs.zig index 4f2e217..44b3d8e 100644 --- a/apps/realmd/d2cs.zig +++ b/apps/realmd/d2cs.zig @@ -382,12 +382,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(version: []const u8) []const u8 { + if (version.len < 4) return ""; + if (version[0] != '1' or version[1] != '.') return ""; + const code = version[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 +438,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 +455,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 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 From ebaaed9ce8efaa48497c73a3725e077119ccf93c Mon Sep 17 00:00:00 2001 From: jaenster Date: Sun, 23 Aug 2026 11:20:34 +0200 Subject: [PATCH 2/3] realmd: the engine a character is made for travels with the request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A character's engine was taken from the connection that created it, and that is the wrong place to take it from: a launcher that lets the player pick one picks it on the CREATION screen, long after the logon that would have decided it. MCP_CHARCREATE's status word has a free high byte — the game only ever uses the low one, for hardcore, died, expansion and ladder — so the era rides there as its own two digits. A stock client sends zero, which still means "the engine of the client that made it", so nothing about a single-version realm changes. It resolves against the tags the realm is CONFIGURED with rather than a table of our own, so a realm can only ever stamp a character with an engine it actually serves. Order is request, then extension, then client: the request wins because it is the only one that knows what was picked. --- apps/realmd/d2cs.zig | 21 +++++++++++++++------ apps/realmd/version.zig | 18 ++++++++++++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/apps/realmd/d2cs.zig b/apps/realmd/d2cs.zig index 44b3d8e..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"); @@ -403,10 +404,10 @@ fn onCharList(c: *DConn, tag: []const u8, body: []const u8) void { /// 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(version: []const u8) []const u8 { - if (version.len < 4) return ""; - if (version[0] != '1' or version[1] != '.') return ""; - const code = version[2..4]; +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 ""; } @@ -517,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(); @@ -554,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/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; From d720f2e13d774524ce1a60d2da40b09a9cba03c4 Mon Sep 17 00:00:00 2001 From: jaenster Date: Sun, 23 Aug 2026 13:23:55 +0200 Subject: [PATCH 3/3] realmd: import a save file onto an account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There was no way to put an existing character on a realm. chars/copy clones one that is already there, which is no help when the character you want is a .d2s on somebody's disk — and a realm with no real characters on it cannot show whether it renders their gear. The name is written into the save and the checksum repaired on the way in, the same rewrite chars/copy does and for the same reason: a .d2s carries its own name, and a client refuses one whose name is not the character it asked for. So a save exported under one name lands correctly under another. It will not overwrite. Of everything this API does, a silent replace is the one with no way back. Base64 because this is a JSON API and a save is binary. --- apps/realmd/admin.zig | 27 +++++++++++++++++++++++++++ apps/realmd/store.zig | 25 ++++++++++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) 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/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 };