Skip to content

Commit b8e934a

Browse files
committed
Fix 5 bugs found in zhonghua2's deep functional test, incl. §7.12/§7.17
recurrences Round-two deep functional test (AGENTS.md §10.7), including two complete real 15-minute net-dead timeout waits. 1. cmds/usr/league.lpc: missing "check" case for logind.lpc's silent internal per-login/reconnect call (league.main(this_player(), "check")) -- fell through to the default branch, printing a meaningless "无效的参数。" to every player on every login/reconnect. Added the case. 2. cmds/std/whisper.lpc: a copy-pasted duplicate tell_object() call printed every whisper to a real player twice (confirmed byte- identical to the pre-existing archive). Removed the duplicate. 3. AGENTS.md §7.12/§6.5 combo: adm/simul_efun/message.lpc defines its own message() wrapper specifically to guard the exclude argument, but tell_room() (and others) called plain message(...) textually before the wrapper's own definition, silently binding to the real driver efun instead and bypassing the existing fix entirely -- reached from user_dump()'s net-dead force-quit path. Added a forward declaration so every call site resolves to the local wrapper regardless of textual order. Live-verified via a real full 15-minute net-dead wait (driver stayed alive, clean reconnect). 4. adm/daemons/logind.lpc: an unguarded lazy-load of MESSAGE_D (never preloaded due to a stale preload entry) throws on its first-ever load each boot (a config-ID/socket_bind() mismatch, same root cause already documented for versiond.lpc/dns_master.lpc) -- broke login for the first player to reconnect/relogin after any fresh boot. Wrapped in catch(), matching this codebase's own established pattern for the same class of lazy-load daemon. 5. adm/daemons/rebornd.lpc: jingyan_open()/wudie_open() had a stale 2-char slice (leftover from the original .c extension) that could never match ".lpc" -- confirmed byte-identical to the pre-existing archive, a genuine post-rename fallout. Combined with an unbounded do-while retry loop, this permanently killed each mystery-entrance feature's self-rescheduling chain on the first bad pick every boot. Fixed the slice width, bounded the retry loop, and added an explicit reschedule-on-failure so the feature always tries again later instead of silently dying. Also guarded qilin_open()'s unguarded call_other on a genuinely-missing room (not fabricated) with the same reschedule pattern, and fixed a stray-semicolon dead conditional in qilin_close(). Also proactively applied the AGENTS.md §7.17 room-reset reentrancy guard to inherit/room/room.lpc as defense-in-depth (the primary whisper-duplication symptom was independently traced to bug 2 above, but the structural reentrancy hazard is real and shared by every room).
1 parent 53d1da9 commit b8e934a

10 files changed

Lines changed: 586 additions & 21 deletions

File tree

libs/zhonghua2/NOTES.md

Lines changed: 393 additions & 0 deletions
Large diffs are not rendered by default.

libs/zhonghua2/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,18 @@ python3 scripts/mudclient.py 127.0.0.1 40028
5454
会导致每个新连接在看完开场画面后立刻被断开,连输入英文名字的提示都
5555
看不到;这是 WASM 环境本身的限制,并非游戏内容或本次移植的问题,原生
5656
(telnet)模式完全不受影响。
57+
- **2026-07 深度功能测试(真实游玩,含两次完整 15 分钟断线重连等待)**
58+
修复了 5 个真实 bug,详见 NOTES.md「深度功能测试」一节。其中最严重的
59+
一个是断线超时强制退出流程(`user_dump()`)里的 `tell_room()` 调用因
60+
绑定顺序问题绕过了已有的参数类型保护,在断线满 15 分钟强制退出时会
61+
抛出未捕获的运行时错误——已修复并用真实的完整 15 分钟等待重新验证过
62+
(驱动全程存活、无报错、重连后角色状态完整)。其余 4 个:每次登录/
63+
重连都会向玩家打印一句无意义"无效的参数。"(`league.lpc` 缺少
64+
`"check"` 分支);每条悄悄话(whisper)都会重复显示两遍(`whisper.lpc`
65+
的复制粘贴 bug);开服后第一个老玩家重新登录会直接卡死(`messaged.lpc`
66+
懒加载时的未捕获错误);以及"惊雁宫"/"舞蝶山庄"/"麒麟窟"神秘入口系统
67+
在开服后第一次尝试开启时必定失败且永久失效(`rebornd.lpc``.c`
68+
`.lpc` 改名后遗留的切片宽度错误 + 两处缺失的空指针检查)。
5769
- 2026-07 WASM 适配补丁之后:上述版本同步(versiond)拦断已在 mudlib 侧
5870
修复——versiond 因缺少 socket 功能而加载失败时,登录流程现在视同
5971
"版本正常"直接放行;同时本机/回环连接也不再受 IP 封禁和同 IP 连接数

libs/zhonghua2/work/adm/daemons/logind.lpc

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,24 @@ private void check_ok(object ob) {
305305
return;
306306
}
307307

308-
user = MESSAGE_D->find_chatter(ob->query("id"));
308+
// MESSAGE_D (/adm/daemons/network/messaged.lpc) is never preloaded (the
309+
// stale /adm/etc/preload entry points at a nonexistent /adm/daemons/
310+
// messaged.lpc, not the real /adm/daemons/network/messaged.lpc), so this
311+
// is normally the FIRST lazy load of it each boot. Its create() calls
312+
// socket_bind() with a port value built from get_config(__MUD_PORT__),
313+
// which resolves to an empty string instead of the real int mud port on
314+
// this driver (a stale include/runtime_config.h config-ID vs. the actual
315+
// driver's numbering, same root cause already documented for
316+
// versiond.lpc/dns_master.lpc) -- socket_bind() then throws "Bad argument
317+
// 2 to socket_bind()" uncaught. Every OTHER call site that can trigger
318+
// this same crash is already isolated (master.lpc's preload() wraps
319+
// dns_master's load in catch(), versiond's is inside a call_out so it
320+
// can't propagate here) -- this was the one unguarded lazy-load call site
321+
// left, and it sat directly in the login path, so it broke login for the
322+
// very first player to reconnect/relogin after any fresh boot. catch()
323+
// it the same way, degrading to "not currently chatting" on failure.
324+
if (catch(user = MESSAGE_D->find_chatter(ob->query("id"))))
325+
user = 0;
309326
if (objectp(user)) {
310327
write("你把正在聊天的ID踢了出去。\n");
311328
MESSAGE_D->user_logout(user, user->name(1) + "从" +

libs/zhonghua2/work/adm/daemons/rebornd.lpc

Lines changed: 90 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -61,24 +61,58 @@ void jingyan_open() {
6161
int i;
6262
int num = random(sizeof(place));
6363
string city = place[num];
64+
int tries;
6465

6566
string *plist = get_dir(city);
6667

67-
do {
68+
// BUG (pre-existing in the original archive, confirmed byte-identical in
69+
// raw/中华2/adm/daemons/rebornd.c): the ".lpc" extension check used to be
70+
// `s_place[(i - 2)..i] != ".lpc"` -- a leftover 2-char-wide slice from
71+
// when this compared against the original ".c" extension (i-2..i is only
72+
// 3 characters, one short of ".lpc"'s 4, and index i is one past the end
73+
// of a 0-indexed string of length i besides), so the comparison could
74+
// NEVER be true and every candidate file was rejected. Combined with
75+
// `get_dir()` legitimately returning 0 (not an array) for a directory
76+
// that doesn't resolve, the do-while below could spin forever retrying
77+
// random picks that always fail, or crash outright indexing into 0 --
78+
// either way call_out("jingyan_close"/reschedule) a few lines down never
79+
// ran, permanently killing this daemon's self-rescheduling chain for the
80+
// rest of the boot after the first bad city pick. Reproduced live via a
81+
// §10.7 deep functional test: debug.log showed both failure modes
82+
// ("*Value being indexed is zero." and "*Too long evaluation. Execution
83+
// aborted.") from this exact function during ordinary ambient play.
84+
// Fixed: correct 4-char slice (bounds-guarded), a bounded retry count
85+
// instead of an unbounded do-while, and an explicit reschedule on
86+
// failure so the mystery-entrance feature always tries again later
87+
// instead of silently dying.
88+
if (!pointerp(plist) || !sizeof(plist)) {
89+
remove_call_out("jingyan_open");
90+
call_out("jingyan_open", 1800);
91+
return;
92+
}
93+
94+
n_place = 0;
95+
for (tries = 0; tries < 200 && !n_place; tries++) {
6896
s_place = plist[random(sizeof(plist))];
6997
//文件名中含有bak的文件不要,继续找下一个
7098
if (strsrch(s_place, "bak") > -1) continue;
71-
//不是c文件的也滤过
99+
//不是lpc文件的也滤过
72100
i = sizeof(s_place);
73-
if (s_place[(i - 2)..i] != ".lpc") continue;
101+
if (i < 4 || s_place[(i - 4)..(i - 1)] != ".lpc") continue;
74102
//如果该房间已经调出在内存里的直接find
75-
//find不到说明没调出在内存里,那就load出来
76-
//load也load不出来大概就有问题了,那就找下一个了
103+
//find不到说明没调出在内存里,那就load出来
104+
//load也load不出来大概就有问题了,那就找下一个了
77105
if (!objectp(n_place = find_object(city + s_place)))
78-
79106
if (!objectp(n_place = load_object(city + s_place)))
80107
continue;
81-
} while (!n_place || !n_place->query("outdoors"));
108+
if (!n_place->query("outdoors")) n_place = 0;
109+
}
110+
111+
if (!n_place) {
112+
remove_call_out("jingyan_open");
113+
call_out("jingyan_open", 1800);
114+
return;
115+
}
82116

83117
n_place->set("exits/jingyangong", JINGYAN);
84118

@@ -114,24 +148,39 @@ void wudie_open() {
114148
int i;
115149
int num = random(sizeof(place));
116150
string city = place[num];
151+
int tries;
117152

118153
string *plist = get_dir(city);
119154

120-
do {
155+
// Same bug/fix as jingyan_open() above -- see the comment there.
156+
if (!pointerp(plist) || !sizeof(plist)) {
157+
remove_call_out("wudie_open");
158+
call_out("wudie_open", 1800);
159+
return;
160+
}
161+
162+
n_place = 0;
163+
for (tries = 0; tries < 200 && !n_place; tries++) {
121164
s_place = plist[random(sizeof(plist))];
122165
//文件名中含有bak的文件不要,继续找下一个
123166
if (strsrch(s_place, "bak") > -1) continue;
124-
//不是c文件的也滤过
167+
//不是lpc文件的也滤过
125168
i = sizeof(s_place);
126-
if (s_place[(i - 2)..i] != ".lpc") continue;
169+
if (i < 4 || s_place[(i - 4)..(i - 1)] != ".lpc") continue;
127170
//如果该房间已经调出在内存里的直接find
128-
//find不到说明没调出在内存里,那就load出来
129-
//load也load不出来大概就有问题了,那就找下一个了
171+
//find不到说明没调出在内存里,那就load出来
172+
//load也load不出来大概就有问题了,那就找下一个了
130173
if (!objectp(n_place = find_object(city + s_place)))
131-
132174
if (!objectp(n_place = load_object(city + s_place)))
133175
continue;
134-
} while (!n_place || !n_place->query("outdoors"));
176+
if (!n_place->query("outdoors")) n_place = 0;
177+
}
178+
179+
if (!n_place) {
180+
remove_call_out("wudie_open");
181+
call_out("wudie_open", 1800);
182+
return;
183+
}
135184

136185
n_place->set("exits/wudie", WUDIE);
137186

@@ -167,6 +216,24 @@ void qilin_open() {
167216
if (!room)
168217
room = load_object("/d/city3/shudao8");
169218

219+
// /d/city3/shudao8.lpc genuinely does not exist in this archive (an
220+
// ordinary content gap, not a conversion bug -- confirmed missing from
221+
// raw/ too). load_object() legitimately returns 0 for that, and the
222+
// unguarded room->set(...) below used to crash with an uncaught "Bad
223+
// argument 1 to EFUN call_other() ... Got: int(0)" (reproduced live via
224+
// a §10.7 deep functional test), which aborted this function before it
225+
// ever reached call_out("qilin_close", ...) a few lines down --
226+
// qilin_close() is what reschedules qilin_open() again, so this single
227+
// crash permanently killed the 麒麟窟 mystery-entrance feature's
228+
// self-rescheduling chain for the rest of the boot, same shape as the
229+
// jingyan_open()/wudie_open() bug fixed above. Guard and reschedule
230+
// instead of crashing; don't fabricate the missing room.
231+
if (!room) {
232+
remove_call_out("qilin_open");
233+
call_out("qilin_open", 1800);
234+
return;
235+
}
236+
170237
room->set("exits/qilinku", QILIN);
171238

172239
message("vision", HIG "【" HIR "江湖传言" HIG "】" HIW
@@ -177,8 +244,15 @@ void qilin_open() {
177244
}
178245

179246
void qilin_close(object room) {
180-
if (room && room->query("exits/qilinku"));
181-
room->delete("exits/qilinku");
247+
// Stray semicolon (pre-existing in the original archive, confirmed
248+
// byte-identical in raw/中华2/adm/daemons/rebornd.c) turned this into an
249+
// empty-body if -- the intended guard never actually gated the
250+
// unconditional room->delete() below. Currently inert in practice since
251+
// qilin_open() (above) now only ever calls this with a real room object,
252+
// but fix the dead conditional anyway rather than leave a trap for any
253+
// future call site.
254+
if (room && room->query("exits/qilinku"))
255+
room->delete("exits/qilinku");
182256

183257
message("vision", HIG "【" HIR "江湖传言" HIG "】" HIW
184258
":成都城外洪水汹涌,麒麟窟再次消失在人世间。\n" NOR, users());

libs/zhonghua2/work/adm/simul_efun/message.lpc

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,25 @@
33
// Update by Lonely At 08/11/2002
44

55
#include <ansi.h>
6+
7+
// AGENTS.md §7.12 / §6.5: this file defines its own `message()` wrapper
8+
// (below) around the real driver efun specifically to guard the 4th
9+
// (exclude) argument -- `tell_room()`'s 2-arg callers (very common; see
10+
// e.g. clone/user/user.lpc's user_dump() net-dead force-quit path) leave
11+
// `exclude` unset, which varargs defaults to int 0, and the real efun's
12+
// `message()` rejects a bare int for that argument ("Bad argument 4 to
13+
// EFUN message()"). The wrapper's guard (`objectp(exclude) ||
14+
// pointerp(exclude) ? exclude : ({})`) already handles this correctly --
15+
// but `tell_room()` (and message_vision() and a few other functions)
16+
// call plain `message(...)` textually BEFORE the wrapper's own
17+
// definition later in this file, so every one of those calls silently
18+
// bound to the REAL driver efun instead of this wrapper (§6.5's
19+
// same-named-as-efun-wrapper-called-before-definition trap), completely
20+
// bypassing the fix that already exists a few hundred lines down. This
21+
// forward declaration makes every call site in the file resolve to the
22+
// local wrapper regardless of textual order.
23+
varargs void message(mixed arg, string message, mixed target, mixed exclude);
24+
625
string sort_msg(string input) {
726
#ifdef DOING_IMPROVED
827
return efun::sort_string(input, 60, 0);

libs/zhonghua2/work/cmds/std/whisper.lpc

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,19 @@ int main(object me, string arg) {
5454
tell_room(environment(me), me->name() + "在" + ob->name()
5555
+ "耳边小声地说了些话。\n", ({ me, ob }));
5656

57+
// BUG (pre-existing in the original archive, confirmed byte-identical
58+
// in raw/中华2/cmds/std/whisper.c): this used to be two separate
59+
// if(!userp(ob)) ... else tell_object(...) blocks, each with its own
60+
// copy of the exact same "在你的耳边悄声说道:" tell_object() in its
61+
// else branch. The first block's else already delivers the whisper to
62+
// a player target; the second block's else was a copy-paste duplicate
63+
// of the same tell_object() call, so every whisper to a real player
64+
// (the common case) was silently printed to them TWICE. Reproduced
65+
// live via a §10.7 deep functional test: /d/register/npc/shuisheng's
66+
// greeting() sends two whispers on a player's first visit to the
67+
// register room, and both showed up doubled. The second block's real
68+
// job is only the NPC-target quest-bonus branch below; a player target
69+
// needs nothing further here now that the message is sent once above.
5770
if (!userp(ob)) ob->relay_whisper(me, msg);
5871
else
5972
tell_object(ob, WHT + me->name() +
@@ -64,9 +77,7 @@ int main(object me, string arg) {
6477
r = ultra_whisper(me, ob, msg);
6578
if (!r)
6679
ob->ultra_whisper(me, msg);
67-
} else
68-
tell_object(ob, WHT + me->name() +
69-
WHT "在你的耳边悄声说道:" + msg + "\n" NOR);
80+
}
7081

7182
//配合长安府传暗号任务新增部分
7283
if (!userp(ob)) {

libs/zhonghua2/work/cmds/usr/league.lpc

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ int main(object me, string arg) {
2727
arg = 0;
2828

2929
switch (args[0]) {
30+
case "check":
31+
// 静默检查同盟情况——adm/daemons/logind.lpc 的 enter_world()/
32+
// reconnect() 在每次登录/重连时都无条件调用
33+
// "/cmds/usr/league"->main(this_player(), "check"),原意是一次
34+
// 静默的内部检查,不应该有任何玩家可见的输出(原始存档里这个
35+
// switch 从未处理过 "check",于是每次登录/重连都落到下面的
36+
// default 分支,向每一个玩家打印一句无意义的"无效的参数。")。
37+
return 1;
38+
3039
case "info":
3140
// 显示同盟的信息
3241
return show_league_info(me, arg);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
#/clone/user/login.lpc
2+
dbase (["registered":1,"purename":"风","password":"$6$njfw9DqNYoinw21R$YBCJsPDAudi/m8tM3DGQTYEih.AZTdgFZP6pDaIirQkkM268/POzP7VihAx9HBZC4bnZAeh9aj.zoASAmJ/hD/","last_from":"127.0.0.1","id":"shenfengid","ad_password":"$6$uigGC/KtvY7sTUGc$cGz3XE7949o.OlgHOpFr4VyZFuJRqXzbieH3I1rV7TuSHhI2P6BdYL5unCZ8n3oXHxSinQ6LJ6kehVnwemt4O0","last_on":1784953231,"surname":"沈","body":"/clone/user/user",])
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#/clone/user/user.lpc
2+
killer ({})
3+
want_kills ({})
4+
dbase (["max_jing":175,"qi":101,"weiwang":100,"title":"普通百姓","jingli":120,"dex":18,"special_skill":(["agile":1,"light":1,"jin":1,]),"jing":101,"water":320,"max_jingli":300,"per":19,"birthday":1784950990,"channels":({"chat","rumor","party","bill","sos","family","ic","rultra",}),"sec_id":"$6$JwESVvEDS5f4Gj6r$ih/vDoiiMCgAHHXGuJVkmXwDq3NNhoRqJeV5zzs8LMWTxsV7hARq8uIxSSr.vn7.r78fFlkSl7wGe18fsvTE8.","race":"人类","env":(["prompt":"time","wimpy":60,"way":1,]),"gender":"男性","startroom":"/d/city/wumiao","con":22,"character":"阴险奸诈","eff_jing":102,"type":"均衡型","potential":101299,"surname":"沈","neili":240,"max_neili":500,"mud_age":391,"name":"沈风","born":"扬州人氏","int":18,"registered":1,"purename":"风","gift":(["bobo":(["rmb":20,]),"kaizhan":1,"jindan":2,]),"points":24,"id":"shenfengid","experience":120,"magic_points":120,"attitude":"peaceful","combat_exp":350000,"limbs":({"头部","颈部","胸口","后心","左肩","右肩","左臂","右臂","左手","右手","腰间","小腹","左腿","右腿","左脚","右脚",}),"score":100,"food":320,"last_read_news":1784950990,"kar":18,"last_save":1784953229,"shen_type":0,"max_qi":225,"unit":"位","age":14,"level":7,"can_speak":1,"str":22,"born_family":"没有","shen":0,"eff_qi":102,])
5+
skills (["force":100,"strike":100,"martial-cognize":100,"unarmed":100,"dodge":100,"sword":100,"parry":100,])
6+
autoload ({})
7+
at_time 1784953230
8+
ban_say_msg ""

libs/zhonghua2/work/inherit/room/room.lpc

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,29 @@ object make_inventory(string file) {
3333
return ob;
3434
}
3535

36+
// AGENTS.md §7.17: setup() (below) calls reset() synchronously as the
37+
// last step of create(), but the driver's own standard behavior can also
38+
// fire a reset() pass on the same freshly-loaded room around the same
39+
// trigger (a player's first move() into it) -- genuinely reentrant, the
40+
// second call starting before the first returns. reset() only marks its
41+
// NPC population done on its LAST line (set_temp("objects", ob)), so a
42+
// reentrant second call sees that tracking mapping still unset and clones
43+
// a second full set of every NPC/object listed in "objects", each with
44+
// its own independently-scheduled init()/call_out side effects (observed
45+
// live: /d/register/npc/shuisheng's greeting() whisper firing twice).
46+
// Only reproduces on a room's first-ever visit after a fresh boot.
47+
nosave int resetting_now;
48+
3649
void reset() {
3750
mapping ob_list, ob;
3851
string *list;
3952
int i, j;
4053
mapping guards;
4154
object *obs;
4255

56+
if (resetting_now) return;
57+
resetting_now = 1;
58+
4359
// remove the action done in this room
4460
delete_temp("been");
4561
if ((guards = query_temp("guarded")) &&
@@ -77,7 +93,10 @@ void reset() {
7793
//
7894
set("no_clean_up", 0);
7995
ob_list = query("objects");
80-
if (!mapp(ob_list)) return;
96+
if (!mapp(ob_list)) {
97+
resetting_now = 0;
98+
return;
99+
}
81100

82101
if (!mapp(ob = query_temp("objects")))
83102
ob = allocate_mapping(sizeof(ob_list));
@@ -115,6 +134,7 @@ void reset() {
115134
}
116135
}
117136
set_temp("objects", ob);
137+
resetting_now = 0;
118138
}
119139

120140
// Redirect item_desc of the door to this function in default.

0 commit comments

Comments
 (0)