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 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-09-30 20:59:21 +09:00
co-authored by Claude Opus 5.5
parent 01cc252a1d
commit f345bd5d3b
7 changed files with 93 additions and 13 deletions
+7 -1
View File
@@ -43,7 +43,13 @@
`item_list_zh_overlay.txt`、`itemdesc_zh_overlay.txt` 每行是目标文件的一整行(UTF-8,第一列 VNUM), `item_list_zh_overlay.txt`、`itemdesc_zh_overlay.txt` 每行是目标文件的一整行(UTF-8,第一列 VNUM),
`build_zh_locale.py` 用它替换同 VNUM 的行(`ROW_FILES`),其余行字节不变;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) ## 物品说明(itemdesc_translate_zh.tsv)
+4
View File
@@ -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
1 # vnum limit type 0 value 0 limit type 1 value 1 (0 = LIMIT_NONE, 1 = LIMIT_LEVEL): the item_proto.txt limits
2 # changed on our server (tools/server/*), mirrored into the client proto by locale_proto_patch.
3 # 70058 移动戒指: level 30 removed (tools/server/ring-warp).
4 70058 0 0 0 0
+1
View File
@@ -1,2 +1,3 @@
# vnum name description: whole itemdesc.txt rows (build_zh_locale.py ROW_FILES) # vnum name description: whole itemdesc.txt rows (build_zh_locale.py ROW_FILES)
50307 小狩猎套装礼包 打开后按职业获得一套1级+9装备:武器、盔甲、头盔、盾牌、手镯、鞋子、项链、耳环,每件5条满值属性。需留15格背包空位,每个角色限开一次。 50307 小狩猎套装礼包 打开后按职业获得一套1级+9装备:武器、盔甲、头盔、盾牌、手镯、鞋子、项链、耳环,每件5条满值属性。需留15格背包空位,每个角色限开一次。
70058 移动戒指 此戒指可将你传送到本国一村、二村或各主要地图,任何等级都可使用,可无限次使用,没有冷却时间。不可交易。
+3 -1
View File
@@ -346,10 +346,12 @@ def build(en: Path, zh: Path, output: Path, client_base: Path,
names_dir / "item_names_zh_overlay.txt") names_dir / "item_names_zh_overlay.txt")
mob_conversion = convert_names(names_dir / "mob_names_zh.txt", mob_names, mob_conversion = convert_names(names_dir / "mob_names_zh.txt", mob_names,
names_dir / "mob_names_zh_overlay.txt") 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( result = subprocess.run(
[str(proto_tool), str(en / "item_proto"), str(en / "mob_proto"), [str(proto_tool), str(en / "item_proto"), str(en / "mob_proto"),
str(item_names), str(mob_names), str(locale_dir / "item_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, check=True, capture_output=True, text=True,
) )
report["proto"] = { report["proto"] = {
+64 -8
View File
@@ -1,5 +1,8 @@
// Offline CP936 name overlay for the 40250 item/mob proto format. // Offline CP936 name overlay for the 40250 item/mob proto format.
// Runtime loading remains the original 40250 path and binary 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/StdAfx.h"
#include "GameLib/ItemData.h" #include "GameLib/ItemData.h"
#include "UserInterface/StdAfx.h" #include "UserInterface/StdAfx.h"
@@ -7,6 +10,7 @@
#include "EterBase/lzo.h" #include "EterBase/lzo.h"
#include <array> #include <array>
#include <cstdio>
#include <cstdint> #include <cstdint>
#include <cstring> #include <cstring>
#include <fstream> #include <fstream>
@@ -67,9 +71,53 @@ std::map<DWORD, std::string> read_names(const char* path) {
return names; return names;
} }
using Limits = std::array<CItemData::TItemLimit, CItemData::ITEM_LIMIT_MAX_NUM>;
std::map<DWORD, Limits> read_limits(const char* path) {
std::map<DWORD, Limits> 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<DWORD>(vnum)];
row[0].bType = static_cast<BYTE>(type0);
row[0].lValue = value0;
row[1].bType = static_cast<BYTE>(type1);
row[1].lValue = value1;
}
return limits;
}
template <class Record>
void apply_limits(Record&, const std::map<DWORD, Limits>&, std::size_t&) {}
template <>
void apply_limits<Item>(Item& record, const std::map<DWORD, Limits>& 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 <class Record>
bool limits_field(std::size_t, DWORD, const std::map<DWORD, Limits>&) { return false; }
template <>
bool limits_field<Item>(std::size_t offset, DWORD vnum, const std::map<DWORD, Limits>& limits) {
return limits.count(vnum) && offset >= offsetof(Item, aLimits) &&
offset < offsetof(Item, aLimits) + sizeof(Item::aLimits);
}
template <class Record> template <class Record>
std::vector<BYTE> patch(const char* source, const char* names_path, std::vector<BYTE> patch(const char* source, const char* names_path,
std::array<DWORD, 4> key, const char* magic, bool versioned) { std::array<DWORD, 4> key, const char* magic, bool versioned,
const std::map<DWORD, Limits>& limits = {}) {
const auto input = read_file(source); const auto input = read_file(source);
const std::size_t count_offset = versioned ? 12 : 4; const std::size_t count_offset = versioned ? 12 : 4;
const std::size_t payload_offset = versioned ? 20 : 12; const std::size_t payload_offset = versioned ? 20 : 12;
@@ -89,10 +137,12 @@ std::vector<BYTE> patch(const char* source, const char* names_path,
std::vector<BYTE> plain(decoded.GetBuffer(), decoded.GetBuffer() + decoded.GetSize()); std::vector<BYTE> plain(decoded.GetBuffer(), decoded.GetBuffer() + decoded.GetSize());
const auto original = plain; const auto original = plain;
const auto names = read_names(names_path); 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) { for (DWORD i = 0; i < count; ++i) {
Record record; Record record;
std::memcpy(&record, plain.data() + std::size_t(i) * sizeof(Record), sizeof(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); const auto found = names.find(record.dwVnum);
if (found == names.end()) { ++missing; continue; } if (found == names.end()) { ++missing; continue; }
if (found->second.size() >= sizeof(record.szLocaleName)) { ++too_long; continue; } if (found->second.size() >= sizeof(record.szLocaleName)) { ++too_long; continue; }
@@ -101,13 +151,16 @@ std::vector<BYTE> patch(const char* source, const char* names_path,
std::memcpy(plain.data() + std::size_t(i) * sizeof(Record), &record, sizeof(Record)); std::memcpy(plain.data() + std::size_t(i) * sizeof(Record), &record, sizeof(Record));
++translated; ++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) { for (DWORD i = 0; i < count; ++i) {
const std::size_t start = std::size_t(i) * sizeof(Record); 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_start = start + offsetof(Record, szLocaleName);
const std::size_t name_end = name_start + sizeof(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) { 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<Record>(byte - start, vnum, limits) &&
plain[byte] != original[byte])
throw std::runtime_error("non-name proto data changed"); throw std::runtime_error("non-name proto data changed");
} }
} }
@@ -124,19 +177,22 @@ std::vector<BYTE> patch(const char* source, const char* names_path,
std::memcmp(verified.GetBuffer(), plain.data(), plain.size()) != 0) std::memcmp(verified.GetBuffer(), plain.data(), plain.size()) != 0)
throw std::runtime_error("encoded proto failed round-trip verification"); throw std::runtime_error("encoded proto failed round-trip verification");
std::cout << magic << " count=" << count << " translated=" << translated 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; return result;
} }
} // namespace } // namespace
int main(int argc, char** argv) { int main(int argc, char** argv) {
if (argc != 7) { 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\n"; 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; return 2;
} }
try { try {
static CLZO lzo; static CLZO lzo;
const auto item = patch<Item>(argv[1], argv[3], item_key, "MIPX", true); const auto item = patch<Item>(argv[1], argv[3], item_key, "MIPX", true, read_limits(argc == 8 ? argv[7] : nullptr));
const auto mob = patch<Mob>(argv[2], argv[4], mob_key, "MMPT", false); const auto mob = patch<Mob>(argv[2], argv[4], mob_key, "MMPT", false);
write_file(argv[5], item); write_file(argv[5], item);
write_file(argv[6], mob); write_file(argv[6], mob);
+2 -1
View File
@@ -4,7 +4,8 @@
选择本国一村 / 二村、升龙谷、影悲沙漠、舒寒山、导炎大地后传送。原版能用 50 次、每次冷却 30 分钟 选择本国一村 / 二村、升龙谷、影悲沙漠、舒寒山、导炎大地后传送。原版能用 50 次、每次冷却 30 分钟
(item_proto value0 / value1,记在物品 socket 0 / 1 里),用完就消失。 (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 金币)。 (`../npc-shops/item-mall`,1 金币)。
sh install.sh # 在服务器上,从本目录运行;然后重启 db 和 game sh install.sh # 在服务器上,从本目录运行;然后重启 db 和 game
+12 -2
View File
@@ -1,19 +1,21 @@
#!/bin/sh #!/bin/sh
# Installs the unlimited 移动戒指 (README.md): run on the server from this directory, then restart db + game. # 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 # 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 set -eu
SERVER=${SERVER:-/usr/metin2/server} SERVER=${SERVER:-/usr/metin2/server}
LOCALE=${LOCALE:-newcibn} LOCALE=${LOCALE:-newcibn}
QUEST="$SERVER/share/locale/$LOCALE/quest" QUEST="$SERVER/share/locale/$LOCALE/quest"
NAMES="$SERVER/share/conf/item_names.txt" NAMES="$SERVER/share/conf/item_names.txt"
PROTO="$SERVER/share/conf/item_proto.txt"
HERE=$(cd "$(dirname "$0")" && pwd) HERE=$(cd "$(dirname "$0")" && pwd)
BACKUP=/root/backup-ringwarp/$(date +%Y%m%d-%H%M%S) BACKUP=/root/backup-ringwarp/$(date +%Y%m%d-%H%M%S)
export LC_ALL=C export LC_ALL=C
mkdir -p "$BACKUP" mkdir -p "$BACKUP"
cp -p "$QUEST/ring_warp.quest" "$NAMES" "$BACKUP/" cp -p "$QUEST/ring_warp.quest" "$NAMES" "$PROTO" "$BACKUP/"
echo "backup: $BACKUP" echo "backup: $BACKUP"
# A GBK trail byte 0x5C would be a backslash to qc. # 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" cat "$BACKUP/item_names.new" > "$NAMES"
mysql -uroot player -e "UPDATE item_proto SET locale_name = '$name' WHERE vnum = 70058" 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. # 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; } 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" ls "$QUEST/object/10031/sig_use"