10 REM"_(C2SLFF4

Hacker News Top 新闻

摘要

一篇文章调查了1980年BASIC游戏《The Wizard's Castle》开头的神秘REM语句,发现它是编码的机器码,通过USR和POKE调用为随机数生成器提供种子。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/07/22 14:21

# 10 REM"_(C2SLFF4 来源: https://beej.us/blog/data/mystery-comment/ 2026\-07\-21 ## 巫师城堡 让我们深入研究*巫师城堡*(源代码)! `` 10 REM"_(C2SLFF4 `` 这是80年代微型计算机BASIC游戏*巫师城堡*的第一行,最初是为Exidy Sorcerer(https://en.wikipedia.org/wiki/Exidy_Sorcerer)平台编写的。 这是一个`REM`语句,是那种语言中的注释。`10`是行号,如果你不熟悉有这种东西的语言的话。 但有趣的是这个`"\_\(C2SLFF4`乱码。是打字错误还是垃圾?不。它原封不动地出现在最初出版于1980年7月的*Recreational Computing*(https://archive.org/details/1980-07-recreational-computing/page/n9/mode/2up)杂志的源代码中。 这到底是什么? 注意:我将在十进制(BASIC友好)和十六进制(程序员友好)之间来回切换。十六进制将以`0x`开头,以`h`结尾,或者明显包含数字A-F。 注意:感谢我的黑客伙伴Josh和Chris,他们为这个做了*大量*的模拟器和研究工作。花了好几个小时,但都是值得的。没有他们的帮助,我要花上10倍的时间。 ## 怀疑 以下是上下文中更多的源代码,但去掉了不相关的部分并添加了一些空格: `` 10 REM"_(C2SLFF4 40 POKE 260,218: POKE 261,1: T = USR(0): T = PEEK(-2049) 80 Q = RND(-(2*T+1)) `` 在BASIC中,`:`是命令分隔符。`POKE`将一个字节值写入指定的内存位置。`POKE`从一个位置读取。Sorcerer BASIC显然使用有符号16位数字,因此地址`-2049`是65536-2049或0xF7FF的十六进制。我们稍后会回到这个地址。 用负数调用伪随机数生成器(PRNG)`RND()`会对其进行种子化(https://en.wikipedia.org/wiki/Random_seed)。旧的PRNG喜欢用奇数来种子化,因此表达式`2*T+1`强制其为奇数。 而`USR()`函数调用一个机器码例程。 第80行是`T`在`PEEK()`初始化后的第一次使用。 这些事情的接近性使它们看起来都与种子化PRNG有关。Sorcerer BASIC没有`RANDOMIZE`命令,所以当时的唯一选择是: - 要求用户输入随机种子。 - 运行一个递增循环,直到用户按下一个键(或类似用户驱动的事情),然后用它作为种子。 - 从现有的软件和/或硬件中获取某种随机性东西的种子。 *巫师城堡*没有采用前两种方法。所以一定是最后一种。 有没有可能`REM`语句中的机器码恰好编码成了ASCII文本?Sorcerer确实使用了ASCII编码。 但仅从ASCII字符就能得到有用的Z80机器码,这似乎有点疯狂。对吧? 尽管如此,这似乎是一个合理的疯狂假设,值得一试,看看我们能发现什么。 ## `USR()`函数 `USR()`调用很有趣——它调用一个机器码函数,但细节取决于系统。但幸运的是,互联网上有大量的技术手册(https://vintagecomputer.ca/files/Exidy%20Sorcerer/)在各种地方(https://www.trailingedge.com/exidy/),我们能够弄清楚它。 地址259有一个`JP`指令(无条件跳转)到一个16位小端绝对地址。 而`POKE`是针对地址260和261的。这是`USR()`函数的跳板(https://en.wikipedia.org/wiki/Trampoline_%28computing%29#Low-level_programming)。你将机器码起始地址`POKE`到地址260和261,然后运行`USR()`来调用它。 `` 40 POKE 260,218: POKE 261,1: T = USR(0): T = PEEK(-2049) `` 按小端反转,这意味着我们关注的是地址`(1 << 8) | 218`,即474。当我们运行`USR(0)`时,它会跳转到地址474处的机器码,该机器码应以`RET`指令结束。 > `USR()`的参数(`0`)作为4字节浮点数存储在RAM的其他地方,以便机器码在需要时可以查看它。结果证明它与此用例无关。另外,我们将`USR()`的返回值赋给`T`,但我不清楚这个返回值是什么。然而,这无关紧要,因为我们立即用下一个赋值覆盖了`T`。 那么...地址474处有什么? ## BASIC RAM 当你输入一行Sorcerer BASIC时,解释器会对其进行标记化(https://en.wikipedia.org/wiki/Lexical_analysis#Tokenization),并将`PRINT`等命令替换为单字节值: - `PRINT`被标记为0x97 - `REM`被标记为0xC3 - 等等。 然后它像链表(https://en.wikipedia.org/wiki/Linked_list)一样存储在内存中,每一行都是一个“节点”。 节点格式是: - 2字节的“下一个”指针 - 2字节的行号 - 代表标记化代码行的字节 - 1字节的空终止符(一个0x00字节) 在Sorcerer上,这个链表在RAM中的起始地址是469。 让我们再看一下神秘行: `` 10 REM"_(C2SLFF4 `` 这意味着该行的节点由以下地址和字节组成: `` 469 下一个指针的低字节 470 下一个指针的高字节 471 行号的低字节 472 行号的高字节 473 `REM` 标记 (0xc3) 474 REM文本的第一个字节,即 `"` 字符!! `` 而474正是`USR()`函数到达的地方!!它实际上是将`REM`语句的文本作为Z80机器码来调用! ## 反汇编,第一次尝试 这是真的吗?让我们试试! 以下是ASCII字符的十六进制值: `` " 22 _ 5F ( 28 C 43 2 32 S 53 L 4C F 46 F 46 4 34 `` 末尾有0x00终止符,但它在Z80中只是`NOP`,所以我们忽略它。 反汇编后,我们得到: `` 22 5F 28 LD (285Fh),HL ; " _ ( 43 LD B,E ; C 32 53 4C LD (4C53h),A ; 2 S L 46 LD B,(HL) ; F 46 LD B,(HL) ; F 34 INC (HL) ; 4 `` 现在,我不想在这里深入Z80汇编的细节,但请相信我的话,这段代码毫无意义。地址似乎没有指向任何特定内容,谁知道开始时`HL`里有什么,`B`从未被读取,重复的`LD B`毫无意义,而且没有`RET`让我们返回BASIC。 这是垃圾。在模拟器中运行它会导致*奇怪的事情*(比如软复位),正如预期的那样。 这暂时走进了死胡同。我们推测可能是印刷错误,打印机没有实际字节的字形,所以用其他字符替换了。但那并不令人满意。 ## `PEEK(-2049)` 那么,我们换个话题,继续看`PEEK`: `` 40 POKE 260,218: POKE 261,1: T = USR(0): T = PEEK(-2049) `` 地址-2049有什么?无符号化后,它相当于0xF7FF。根据文档,这是内存映射屏幕文本的最后一个字节,代表屏幕右下角的当前字符。 然后我们立即用这个值来种子化PRNG: `` 40 [ ... ] T = PEEK(-2049) 80 Q = RND(-(2*T+1)) `` 现在我要用我的魔力,查看你打开的一个终端窗口,读取屏幕右下角的字符。有点模糊,但如果我集中注意力... 是的,是一个空格字符,对吧! 我猜对了吗?那将花费200美元。 现在,空格是32,所以每次游戏都用`-(2*32+1)`来种子化PRNG,会让这个随机生成的地牢的重玩性很差,所以`USR()`函数**一定**与之有关。比如它将某个东西放在地址0xF7FF的屏幕上,然后`PEEK()`读取它。屏幕不久后就会被清除,所以玩家可能不会注意到右下角临时放置的一个字符的闪烁。 ## RTFM 巫师城堡源代码 其原始点阵打印机荣耀的源代码 所以我们强烈怀疑这一切都是PRNG种子化过程的一部分。要是有什么方法可以证明就好了! 就在这时,Josh注意到杂志中程序发布时的一行文字:“第一个注释是一个模拟RANDOM函数的机器语言例程。” 老天。这教训了我不要不读东西。 在BASIC中,随机函数是`RND()`,而不是`RANDOM`,用负参数来种子化PRNG,用正参数来获取下一个随机数,历史上是0到1之间的随机浮点数结果。 所以作者可能指的是`RANDOMIZE`,它在Microsoft BASIC中很流行,用于将随机数种子设置为特定值或提示用户输入一个。 简而言之,它应该做我们认为的事情,但机器码似乎根本不是在做那个。 ## 突破 Josh和我一直在用MAME(https://en.wikipedia.org/wiki/MAME)运行Sorcerer模拟器(感谢Josh分享ROM让我开始),而Chris有另一个基于Java的模拟器,他已经让它工作了。 在MAME中,我们输入了`REM`语句并查看了内存,它有着如上反汇编的无意义的字节和机器码。而且它没有正确运行。 但Chris找到了一个游戏磁带映像,并在他的模拟器中加载了它。源代码的前两行看起来像这样: `` 10 REM"_(C2SLFF4F4F4 15 REM ED 5F 28 FC 32 FF F7 C9 (在 O1DA) `` 哇!!另一位作者已经用机器码的十六进制注释了源代码!不仅它以Z80的0xC9`RET`结尾,而且它就在那里有那个右下角屏幕地址0xF7FF! `REM`文本到底是如何映射到那个的?像0xED这样的东西显然是非ASCII的。但等等,有些字符是,并且它们的位置与我们看到的`REM`中的匹配! `` " 5F _ 28 ( C 32 2 S L F `` 实际值是什么?Chris加载了程序并查看: `` FOR I=474 TO 489: PRINT PEEK(I): NEXT I 237 95 40 252 50 255 247 201 70 52 70 52 32 0 18 2 READY `` 你可以看到下面的0x00空终止符。以及行尾隐藏的空格。237是0xED,95是0x5F... 它匹配了第15行的注释! 让我们准备好*反汇编*!我将数字转换为十六进制并开始。 `` ed 5f LD A,R " _ 28 fc JR Z,-4 ( C 32 ff f7 LD (F7FF),A 2 S L c9 RET F 46 LD B,(HL) F 34 INC (HL) 4 46 LD B,(HL) F 34 INC (HL) 4 20 00 JR NZ,0 space null `` 稍后,我会发现`RET`之后的部分被错误反汇编,并且我对哪些`REM`字符映射到哪些十六进制值的注释是错误的,但现在谁在乎呢!反正一切都在`RET`处结束了。 而代码正是我们一直在寻找的!让我们看看前几条汇编指令: `` LD A,R ; 将R寄存器复制到累加器 JR Z,-4 ; 如果结果为0,跳回上一条指令 LD (F7FF),A ; 将累加器复制到地址F7FFh RET ; 返回 `` 它做什么?Z80上的`R`寄存器很有趣,因为它每取一条指令就递增一次。也许?取决于你问的是哪个资源。无论如何,相对于人类时间尺度,它非常频繁地递增。而且Sorcerer会忙等待(https://en.wikipedia.org/wiki/Busy_waiting)输入,所以当你最终运行游戏时,它里面有一个相当随机的值。 但是用`0`来种子化许多较弱的PRNG是个坏主意(之后它们往往会只产生`0`),所以如果恰好得到`R`中的`0`,代码会重试。但如果非零,它会将该值存储到地址0xF7FF,即屏幕右下角!然后`PEEK()`拾取它并用它来种子化PRNG! 不过,`R`寄存器只递增低7位,意味着它只能有128个不同的值。我们丢弃了`0`,所以实际上这意味着在Sorcerer上,只能玩127个不同的随机生成的地牢。真糟糕! 所以我们看到的字形根本不是ASCII。我们的第一次反汇编注定失败,因为`REM`违反了所有列表文本都是ASCII的假设。 为了验证,我写了一个新程序,只有一个`REM`后跟一堆空格(以便在空终止符前留出空间操作)。然后我`POKE`入了正确的值并得到了一个列表。 `` 10 REM POKE 474,237 READY POKE 475,95 READY POKE 476,40 READY LIST 10 REM"_( READY `` 成功了!那些值给了我原始源代码列表中出现的`"\_(`字形! ## 你能输入它吗? 80年代这类杂志的整个想法是,你从邮件或报摊得到它,然后辛苦地花几个小时输入并确保正确。 这并不容易。这是*巫师城堡*的另一个摘录: `` 1070 IFFL=0THENPRINT:PRINT"** HEY BRIGHT ONE, YOU'RE OUT OF FLARES":GOTO620 1080 PRINT:PRINT:FL=FL-1:A=X:B=Y:FORQ1=A-1TOA+1:X=FNB(Q1):FORQ2=B-1TOB+1:Y=FNB(Q2) 1090 Q=FNE(PEEK(FND(Z))):POKEFND(Z),Q:PRINTI$(Q);" ";:NEXTQ2:PRINT:PRINT:NEXTQ1:X=A:Y=B 1100 GOSUB 3400:GOTO620 1110 IFLF=0THENPRINT:PRINT"** YOU DON'T HAVE A LAMP, ";R$(RC):GOTO620 1120 PRINT:PRINT"WHERE DO YOU SHINE THE LAMP (N,S,E, OR W) ";:GOSUB3290 1130 A=X:B=Y:X=FNB(X+(O$="N")-(O$="S")):Y=FNB(Y+(O$="W")-(O$="E")) 1140 IFA-X+B-Y=0THENPRINT:PRINT"** TURKEY! THAT'S NOT A DIRECTION":GOTO620 `` 简直是代码呕吐物。但我们这些输入这些东西的人都很熟练,能把它弄对。所以当我们看到这样的东西: `` 10 REM"_(C2SLFF4 `` 你可以打赌我们精确地输入了它。 但现在我们知道,如果你假设那些是ASCII编码的字形,这不会奏效。 也许有一些Sorcerer程序员熟知的传说,他们知道让它工作的魔法咒语。 或者也许他只是写了这个来获得一些操作空间: `` 10 REMF4F4F4F4F4 `` 然后像我上面那样手动`POKE`入机器码,得到这个: `` 10 REM"_(C2SLFF4 `` 然后从未提及如何操作,在发表的兴奋中忘记了,就这样发布了。 但那个`F4`东西是怎么回事?它真的很困扰我。 ## `F4`s F-4 Phantom 一架F-4幻影。这是一个航空/编程双关语。我特此免除自己因诉讼而产生的责任。 在我的版本中,我们有: `` 10 REM"_(C2SLFF4 `` Chris的版本有一些额外的F4: `` 10 REM"_(C2SLFF4F4F4 `` 我们做了Chris版本的内存转储,得到了以下内容: `` 237 95 40 252 50 255 247 201 70 52 70 52 32 0 18 2 `` 注意到关于`F4`s的奇怪之处了吗?我也没有。`REM`中有*三个*,但内存转储中只有*两个*(70,52对)! 而且不仅如此——`L`在哪里?有点不对劲。之前,我手动`POKE`入了前三个字符做了一个健全性检查,但让我们试试其他的。 我将在这里重新创建整个机器码,看看会出现什么。 `` 10 REMXXXXXXXXXXXXXX 20 FOR I=474 TO 481: READ X: POKE I,X: NEXT I 30 DATA 237, 95, 40, 252, 50, 255, 247, 201 `` 然后我运行它,列出它。我得到: `` 10 REM"_(C2SLFF4XXXXXX 20 FOR I=474 TO 481: READ X: POKE I,X: NEXT I 30 DATA 237, 95, 40, 252, 50, 255, 247, 201 `` 等等!我的`X`向右移动了两个字符!这是怎么回事?你不能就这样插入字符。就好像输出中多了两个字符。我POKE了八个值,但在`X`s之前打印了10个字符! 让我们转储RAM看看有什么。我将注释输出,但——剧透——我的注释是错误的: `` 237 " 95 _ 40 ( 252 C 50 2 255 S 247 L 201 F 88 X ← 没有额外的F或4! 88 X 88 X 88 X `` 没有`F`或`4`。它直接从我的最后一个201(在`DATA`中)直接到一堆`X`(88)。那么为什么我在列表中看到它们? 让我们具体一点。我将手动将255、247和201插入到`REM`中,看看能得到什么。

相似文章

C程序员再犯可读性罪行

Hacker News Top

2025年国际混淆C代码大赛获奖者揭晓,共有23个参赛作品,其中包括Adrian Cable的Subleq计算机模拟器,该模拟器通过单指令集架构实现软件保存。

objdump -g 中的任意代码执行

Lobsters Hottest

objdump -g 中存在一个安全漏洞,由于 FR30 重定位处理程序缺少边界检查,通过精心构造的 FR30 目标文件可实现任意代码执行,单个漏洞利用即可绕过 ASLR 及其他缓解措施。

Commodore 64: Mercenary

Hacker News Top

本文详细介绍了 Commodore 64 游戏 Mercenary 中的一个漏洞,该漏洞通过升降机制使玩家能够提前进入某些区域,同时还解释了游戏的渲染和内存管理技术。

Sleepwalker: 带有自定义命令语言的被动后门

Hacker News Top

SLEEPWALKER 是一种被动后门恶意软件,在触发前保持休眠状态,直到接收到包含自定义命令语言程序的特定网络数据包。文章详细介绍了其设计、操作和逆向工程分析。

为什么是0xCAFEBABE?

Lobsters Hottest

本文内容基于1996年的一次新闻组讨论,探讨了Java类文件魔数0xCAFEBABE的起源和原因。