From f345bd5d3bfc6df5986f878eddb47293e661846a Mon Sep 17 00:00:00 2001 From: shenlei Date: Wed, 30 Sep 2026 20:59:21 +0900 Subject: [PATCH] Ring 70058: usable from level 1, tooltip says unlimited and no cooldown The item_proto limit (LEVEL 30) kept new characters from using the warp ring. ring-warp/install.sh now clears it in share/conf/item_proto.txt and the player.item_proto mirror. The client proto gets the same limit through a new item_limits_server.tsv fed to locale_proto_patch, and the zh itemdesc overlay replaces the "50 uses" text. Verified on 203: a fresh level-1 character opened the warp menu four times in a row (one real warp in between), ring still in the bag. Co-Authored-By: Claude Opus 5.5 --- data/zh_40250/README.md | 8 ++- data/zh_40250/item_limits_server.tsv | 4 ++ data/zh_40250/itemdesc_zh_overlay.txt | 1 + scripts/build_zh_locale.py | 4 +- tests/port/locale_proto_patch.cpp | 72 ++++++++++++++++++++++++--- tools/server/ring-warp/README.md | 3 +- tools/server/ring-warp/install.sh | 14 +++++- 7 files changed, 93 insertions(+), 13 deletions(-) create mode 100644 data/zh_40250/item_limits_server.tsv diff --git a/data/zh_40250/README.md b/data/zh_40250/README.md index 8d8fbad1..3b9820cd 100644 --- a/data/zh_40250/README.md +++ b/data/zh_40250/README.md @@ -43,7 +43,13 @@ `item_list_zh_overlay.txt`、`itemdesc_zh_overlay.txt` 每行是目标文件的一整行(UTF-8,第一列 VNUM), `build_zh_locale.py` 用它替换同 VNUM 的行(`ROW_FILES`),其余行字节不变;VNUM 不存在就报错。 -用于服务端改了用途的物品,例如 50307 小狩猎套装礼包(`tools/server/hunt-set`)换成礼盒图标和中文说明。 +用于服务端改了用途的物品,例如 50307 小狩猎套装礼包(`tools/server/hunt-set`)换成礼盒图标和中文说明, +70058 移动戒指(`tools/server/ring-warp`)写明不限等级、无限次、无冷却。 + +## 物品限制(item_limits_server.tsv) + +服务端 `item_proto.txt` 里改过的物品使用限制(每行 VNUM、限制类型 0、值 0、限制类型 1、值 1;0 = 无,1 = 等级), +由 `locale_proto_patch` 写进客户端 `item_proto`,提示框显示的等级要求与服务端一致。其余字段仍逐字节校验不变。 ## 物品说明(itemdesc_translate_zh.tsv) diff --git a/data/zh_40250/item_limits_server.tsv b/data/zh_40250/item_limits_server.tsv new file mode 100644 index 00000000..4d4bbf8d --- /dev/null +++ b/data/zh_40250/item_limits_server.tsv @@ -0,0 +1,4 @@ +# vnum limit type 0 value 0 limit type 1 value 1 (0 = LIMIT_NONE, 1 = LIMIT_LEVEL): the item_proto.txt limits +# changed on our server (tools/server/*), mirrored into the client proto by locale_proto_patch. +# 70058 移动戒指: level 30 removed (tools/server/ring-warp). +70058 0 0 0 0 diff --git a/data/zh_40250/itemdesc_zh_overlay.txt b/data/zh_40250/itemdesc_zh_overlay.txt index 71d656fb..1749d604 100644 --- a/data/zh_40250/itemdesc_zh_overlay.txt +++ b/data/zh_40250/itemdesc_zh_overlay.txt @@ -1,2 +1,3 @@ # vnum name description: whole itemdesc.txt rows (build_zh_locale.py ROW_FILES) 50307 小狩猎套装礼包 打开后按职业获得一套1级+9装备:武器、盔甲、头盔、盾牌、手镯、鞋子、项链、耳环,每件5条满值属性。需留15格背包空位,每个角色限开一次。 +70058 移动戒指 此戒指可将你传送到本国一村、二村或各主要地图,任何等级都可使用,可无限次使用,没有冷却时间。不可交易。 diff --git a/scripts/build_zh_locale.py b/scripts/build_zh_locale.py index a9b1adcc..8b94f2eb 100644 --- a/scripts/build_zh_locale.py +++ b/scripts/build_zh_locale.py @@ -346,10 +346,12 @@ def build(en: Path, zh: Path, output: Path, client_base: Path, names_dir / "item_names_zh_overlay.txt") mob_conversion = convert_names(names_dir / "mob_names_zh.txt", mob_names, names_dir / "mob_names_zh_overlay.txt") + # item_limits_server.tsv: item limits changed on our server, so the tooltip matches what it enforces. + limits = names_dir / "item_limits_server.tsv" result = subprocess.run( [str(proto_tool), str(en / "item_proto"), str(en / "mob_proto"), str(item_names), str(mob_names), str(locale_dir / "item_proto"), - str(locale_dir / "mob_proto")], + str(locale_dir / "mob_proto")] + ([str(limits)] if limits.is_file() else []), check=True, capture_output=True, text=True, ) report["proto"] = { diff --git a/tests/port/locale_proto_patch.cpp b/tests/port/locale_proto_patch.cpp index 6212361a..126e0b8e 100644 --- a/tests/port/locale_proto_patch.cpp +++ b/tests/port/locale_proto_patch.cpp @@ -1,5 +1,8 @@ // Offline CP936 name overlay for the 40250 item/mob proto format. // Runtime loading remains the original 40250 path and binary format. +// An optional item limits table (vnum, limit type 0, value 0, limit type 1, value 1) mirrors the few +// item_proto.txt limit changes made on our server (tools/server/*), so the tooltip states what the +// server enforces; only aLimits of the listed items may change. #include "GameLib/StdAfx.h" #include "GameLib/ItemData.h" #include "UserInterface/StdAfx.h" @@ -7,6 +10,7 @@ #include "EterBase/lzo.h" #include +#include #include #include #include @@ -67,9 +71,53 @@ std::map read_names(const char* path) { return names; } +using Limits = std::array; + +std::map read_limits(const char* path) { + std::map limits; + if (!path) return limits; + std::ifstream stream(path); + if (!stream) throw std::runtime_error(std::string("cannot read ") + path); + std::string line; + while (std::getline(stream, line)) { + if (line.empty() || line[0] == '#') continue; + unsigned long vnum, type0, type1; + long value0, value1; + if (std::sscanf(line.c_str(), "%lu %lu %ld %lu %ld", &vnum, &type0, &value0, &type1, &value1) != 5) + throw std::runtime_error("bad item limits row: " + line); + Limits& row = limits[static_cast(vnum)]; + row[0].bType = static_cast(type0); + row[0].lValue = value0; + row[1].bType = static_cast(type1); + row[1].lValue = value1; + } + return limits; +} + +template +void apply_limits(Record&, const std::map&, std::size_t&) {} + +template <> +void apply_limits(Item& record, const std::map& limits, std::size_t& changed) { + const auto found = limits.find(record.dwVnum); + if (found == limits.end()) return; + std::memcpy(record.aLimits, found->second.data(), sizeof(record.aLimits)); + ++changed; +} + +template +bool limits_field(std::size_t, DWORD, const std::map&) { return false; } + +template <> +bool limits_field(std::size_t offset, DWORD vnum, const std::map& limits) { + return limits.count(vnum) && offset >= offsetof(Item, aLimits) && + offset < offsetof(Item, aLimits) + sizeof(Item::aLimits); +} + template std::vector patch(const char* source, const char* names_path, - std::array key, const char* magic, bool versioned) { + std::array key, const char* magic, bool versioned, + const std::map& limits = {}) { const auto input = read_file(source); const std::size_t count_offset = versioned ? 12 : 4; const std::size_t payload_offset = versioned ? 20 : 12; @@ -89,10 +137,12 @@ std::vector patch(const char* source, const char* names_path, std::vector plain(decoded.GetBuffer(), decoded.GetBuffer() + decoded.GetSize()); const auto original = plain; const auto names = read_names(names_path); - std::size_t translated = 0, missing = 0, too_long = 0; + std::size_t translated = 0, missing = 0, too_long = 0, limited = 0; for (DWORD i = 0; i < count; ++i) { Record record; std::memcpy(&record, plain.data() + std::size_t(i) * sizeof(Record), sizeof(Record)); + apply_limits(record, limits, limited); + std::memcpy(plain.data() + std::size_t(i) * sizeof(Record), &record, sizeof(Record)); const auto found = names.find(record.dwVnum); if (found == names.end()) { ++missing; continue; } if (found->second.size() >= sizeof(record.szLocaleName)) { ++too_long; continue; } @@ -101,13 +151,16 @@ std::vector patch(const char* source, const char* names_path, std::memcpy(plain.data() + std::size_t(i) * sizeof(Record), &record, sizeof(Record)); ++translated; } - // No item stats, VNUM, canonical name, or mob gameplay fields may change. + // No item stats, VNUM, canonical name, or mob gameplay fields may change (but the listed item limits). for (DWORD i = 0; i < count; ++i) { const std::size_t start = std::size_t(i) * sizeof(Record); const std::size_t name_start = start + offsetof(Record, szLocaleName); const std::size_t name_end = name_start + sizeof(Record::szLocaleName); + DWORD vnum; + std::memcpy(&vnum, plain.data() + start + offsetof(Record, dwVnum), sizeof(vnum)); for (std::size_t byte = start; byte < start + sizeof(Record); ++byte) { - if ((byte < name_start || byte >= name_end) && plain[byte] != original[byte]) + if ((byte < name_start || byte >= name_end) && !limits_field(byte - start, vnum, limits) && + plain[byte] != original[byte]) throw std::runtime_error("non-name proto data changed"); } } @@ -124,19 +177,22 @@ std::vector patch(const char* source, const char* names_path, std::memcmp(verified.GetBuffer(), plain.data(), plain.size()) != 0) throw std::runtime_error("encoded proto failed round-trip verification"); std::cout << magic << " count=" << count << " translated=" << translated - << " missing=" << missing << " too_long=" << too_long << '\n'; + << " missing=" << missing << " too_long=" << too_long; + if (limited) std::cout << " limits=" << limited; + std::cout << '\n'; return result; } } // namespace int main(int argc, char** argv) { - if (argc != 7) { - std::cerr << "usage: locale_proto_patch item_proto mob_proto item_names_gbk mob_names_gbk out_item out_mob\n"; + if (argc != 7 && argc != 8) { + std::cerr << "usage: locale_proto_patch item_proto mob_proto item_names_gbk mob_names_gbk out_item out_mob" + " [item_limits]\n"; return 2; } try { static CLZO lzo; - const auto item = patch(argv[1], argv[3], item_key, "MIPX", true); + const auto item = patch(argv[1], argv[3], item_key, "MIPX", true, read_limits(argc == 8 ? argv[7] : nullptr)); const auto mob = patch(argv[2], argv[4], mob_key, "MMPT", false); write_file(argv[5], item); write_file(argv[6], mob); diff --git a/tools/server/ring-warp/README.md b/tools/server/ring-warp/README.md index 244d17e9..bb5c87e9 100644 --- a/tools/server/ring-warp/README.md +++ b/tools/server/ring-warp/README.md @@ -4,7 +4,8 @@ 选择本国一村 / 二村、升龙谷、影悲沙漠、舒寒山、导炎大地后传送。原版能用 50 次、每次冷却 30 分钟 (item_proto value0 / value1,记在物品 socket 0 / 1 里),用完就消失。 -这里的 `ring_warp.quest` 去掉了次数和冷却:不再读写 socket,戒指不会消耗。在百店老板娘的商城里卖 +这里的 `ring_warp.quest` 去掉了次数和冷却:不再读写 socket,戒指不会消耗。`install.sh` 同时去掉 +`item_proto.txt` 里的 30 级限制(服务端 `CHARACTER::CanUseItem` 按它拒绝低等级角色使用),新建的角色也能用。在百店老板娘的商城里卖 (`../npc-shops/item-mall`,1 金币)。 sh install.sh # 在服务器上,从本目录运行;然后重启 db 和 game diff --git a/tools/server/ring-warp/install.sh b/tools/server/ring-warp/install.sh index 80e02ac9..b442e7b1 100755 --- a/tools/server/ring-warp/install.sh +++ b/tools/server/ring-warp/install.sh @@ -1,19 +1,21 @@ #!/bin/sh # Installs the unlimited 移动戒指 (README.md): run on the server from this directory, then restart db + game. # Replaces quest ring_warp with ./ring_warp.quest (GBK), recompiles all quests with make.py, and names -# item 70058 移动戒指 on the server (item_names.txt and item_proto.locale_name; the client has its own name). +# item 70058 移动戒指 on the server (item_names.txt and item_proto.locale_name; the client has its own name), and +# drops its level 30 limit in item_proto.txt (CHARACTER::CanUseItem refuses it to lower characters). set -eu SERVER=${SERVER:-/usr/metin2/server} LOCALE=${LOCALE:-newcibn} QUEST="$SERVER/share/locale/$LOCALE/quest" NAMES="$SERVER/share/conf/item_names.txt" +PROTO="$SERVER/share/conf/item_proto.txt" HERE=$(cd "$(dirname "$0")" && pwd) BACKUP=/root/backup-ringwarp/$(date +%Y%m%d-%H%M%S) export LC_ALL=C mkdir -p "$BACKUP" -cp -p "$QUEST/ring_warp.quest" "$NAMES" "$BACKUP/" +cp -p "$QUEST/ring_warp.quest" "$NAMES" "$PROTO" "$BACKUP/" echo "backup: $BACKUP" # A GBK trail byte 0x5C would be a backslash to qc. @@ -28,6 +30,14 @@ awk -F'\t' -v OFS='\t' -v name="$name" '$1 == 70058 { $2 = name; n++ } { print } cat "$BACKUP/item_names.new" > "$NAMES" mysql -uroot player -e "UPDATE item_proto SET locale_name = '$name' WHERE vnum = 70058" +# LIMIT_TYPE0 / LIMIT_VALUE0 (columns 15, 16): LEVEL 30 -> none. The db process loads item_proto.txt at boot +# and mirrors it into the item_proto table. +awk -F'\t' -v OFS='\t' '$1 == 70058 { if ($15 == "LEVEL") { $15 = "LIMIT_NONE"; $16 = 0 }; n++ } { print } + END { if (n != 1) { print "item_proto.txt: 70058 not found" > "/dev/stderr"; exit 1 } }' \ + "$PROTO" > "$BACKUP/item_proto.new" +cat "$BACKUP/item_proto.new" > "$PROTO" +mysql -uroot player -e "UPDATE item_proto SET limittype0 = 0, limitvalue0 = 0 WHERE vnum = 70058 AND limittype0 = 1" + # make.py's shebang (/usr/local/bin/python) does not exist on 203. cd "$QUEST" && /usr/local/bin/python2.7 make.py > "$BACKUP/make.log" 2>&1 || { tail "$BACKUP/make.log" >&2; exit 1; } ls "$QUEST/object/10031/sig_use"