public enum Singleton { INSTANCE(42); private int answer; Singleton(int answer) { this.answer = answer; } public int getAnswer() { return this.answer; }}
但是如果在面试,面试官大概率是不会满意这份代码的。令人满意的代码,通常是下面这份双重检查锁定式的单例实现:
public class Singleton { private static volatile Singleton INSTANCE; public static Singleton getInstance() { Singleton instance = INSTANCE; if (instance == null) { synchronized (Singleton.class) { if (INSTANCE == null) { INSTANCE = new Singleton(42); } } } return instance; } private int answer; public Singleton(int answer) { this.answer = answer; } public int getAnswer() { return this.answer; }}
这份代码无疑是正确的,synchronized
保证了单例不会被重复实例化,volatile
保证了 INSTANCE
对其他线程可见。但是如果去掉 volatile
呢?
在去掉 volatile
之后,如果我们并发的调用 Singleton.getInstance().getAnswer()
,可能会得到 42
,以及 0
。解释这个结果,需要介绍一点 Java 内存模型的知识。
对于并发代码,我们会关注的是读写的同步:我们希望能读到”在此之前应当已经写入的东西”,不要读到”未来才会发生的东西”,但是在复杂的现代计算机系统中,这并不是一件简单的事。在硬件上,乱序执行会导致读写的顺序改变;软件上,编译器会优化读写,相互无依赖的读写可能以任意顺序进行。
如何获得确定的执行顺序呢?Java 内存模型通过一系列 happens-before 顺序约束执行顺序。如果我们有两个操作 x, y (操作 action,比如读变量、写变量、锁同步)满足 happens-before 规则,那么程序上可以认为 y 操作进行时可以观察到 x 的写入,记作 hb(x, y)
。
部分顺序如下(原始定义更为复杂,此处已经化简过):
没有定义 happens before 的情形,就可能观察到任意的顺序。例如,线程 A 按顺序写入 a b 两个普通变量,但是线程 B 不一定能观察到 a b 按顺序写入了,相反,线程 B 可能只能看到 b 被写入,但是 a 尚未写入。这种情形是最为普遍的未同步情景。
回到刚刚的代码,我们把不包含 volatile 的版本抽象成一串操作:
public class Singleton { private static Singleton INSTANCE; public static Singleton getInstance() { Singleton instance = INSTANCE; // a: 读取 INSTANCE if (instance == null) { synchronized (Singleton.class) { // b: 对 Singleton.class 上锁 if (INSTANCE == null) { // c: 读取 INSTANCE INSTANCE = new Singleton(42); // d: 写入 INSTANCE } } // e: 对 Singleton.class 解锁 } return instance; } private int answer; public Singleton(int answer) { this.answer = answer; // f: 写入 answer } public int getAnswer() { return this.answer; // g: 读取 answer }}
我们期望最终的结果是,两个线程调用 getInstance().getAnswer()
都返回 42,假设两个线程的操作分别为 a1, a2, b1, b2 …,,为了方便我们将 hb(x, y) 记作 x < y,那么有:
线程 1 完成同步块内的操作后,我们观察线程 2 的执行。如果 a2 的结果是 INSTANCE == null
,那么:
而如果 a2 的结果是 INSTANCE != null
,那么:
那么 volatile 做了什么呢?volatile 引入了写后读的顺序,也就是说加入了 d1 < a2,那么:
INSTANCE == null
,那么同上INSTANCE != null
,我们仍然有 f1 < d1 < a2 < g2,因此 g2 能观察到 f1,线程 2 读到了 42volatile
修饰符当前的语义是在 Java 1.5 引入的,准确的说是 JSR 133。在更早的版本,volatile
没有 happens before 语义,因此不能用于实现 DCL 模式,这也是 Effective Java 中提到的 DCL is broken 的原因。
如果读者真的去看了 JLS 的 Memory Model 部分,应该会很容易注意到接下来的一个章节,其中提到了 final 字段的特殊语义:
An object is considered to be completely initialized when its constructor finishes. A thread that can only see a reference to an object after that object has been completely initialized is guaranteed to see the correctly initialized values for that object’s final fields.
线程如果观察到一个完成初始化的对象,Java 可以保证同时能观察到初始化后的 final 字段。
实际的实现上,JVM 为写入 final 字段的构造方法退出时插入了一个 StoreStore|StoreLoad 内存屏障,保证了写入 final 字段的操作发生在”发布”这个对象之前。相关代码如下:
void Parse::do_exits() { // ... // Figure out if we need to emit the trailing barrier. The barrier is only // needed in the constructors, and only in three cases: // // 1. The constructor wrote a final. The effects of all initializations // must be committed to memory before any code after the constructor // publishes the reference to the newly constructed object. Rather // than wait for the publication, we simply block the writes here. // Rather than put a barrier on only those writes which are required // to complete, we force all writes to complete. // // 2. ... // ... if (method()->is_initializer() && (wrote_final() || (AlwaysSafeConstructors && wrote_fields()) || (support_IRIW_for_not_multiple_copy_atomic_cpu && wrote_volatile()))) { _exits.insert_mem_bar(Op_MemBarRelease, alloc_with_final()); // ... } // ...}
也就是说,文章开始的代码,也可以这么写:
public class Singleton { private static Singleton INSTANCE; public static Singleton getInstance() { Singleton instance = INSTANCE; if (instance == null) { synchronized (Singleton.class) { if (INSTANCE == null) { INSTANCE = new Singleton(42); } } } return instance; } private final int answer; public Singleton(int answer) { this.answer = answer; } public int getAnswer() { return this.answer; }}
当然,这里的字段是 int
,如果是一个普通的引用类型字段,这里可能还是需要 volatile 保证正确性。
https://www.cs.umd.edu/\~pugh/java/memoryModel/jsr-133-faq.html#finalRight
]]>你听说过这些东西吗:
上面的东西大多都是在 Java 7 出现的,也是 mlvm 项目的主要产出,意图打造一个可以允许多种语言的虚拟机。这个项目又被称为 the Da Vinci Machine Project。
这张图就是出自达芬奇,已经快有 600 年的历史,而它描述的自然是一个直升机的设想。
mlvm 项目的历史也超过十年了。在 2009 年,mlvm 项目展现了雄伟的野心:
动态调用也就是 Dynamic Invocation,已经在 Java 7 中很好地实现了 —— invokedynamic
指令。
这条指令,不同于已存在的四个指令,是允许动态调用的,同时也包含了其他的许多好处,比如消除了 int 这样的原始类型装箱拆箱的消耗。
它的动态调用允许 Java 代码更改某处调用点(调用点,当然就是 CallSite
)实际调用的方法,就像写入一个字段一样轻松简单。而这里“实际调用的方法”,就是下面的轻量方法对象。
在本文写作时(Java 19 已发布),Java 本身不带有任何涉及动态调用的代码。虽说如此,Java 中还是有不少地方使用到了 invokedynamic
。
Lambda 表达式中,invokedynamic
指令用于生成接口的实现类。
从 Java 9 开始,invokedynamic
用于实现字符串拼接。
从 Java 16 开始,Record 类中 invokedynamic
用于生成 toString
(该实现其实很不好)、equals
、hashCode
这样的方法。
从 Java 17 开始,switch 表达式也使用 invokedynamic
生成 switch table。
除了 Lambda 表达式的实现之外,其他几个调用的 MH 都是使用 MethodHandles
中的方法进行组合而实现。
以上内容都不是动态调用,反而倒更像是某种意义上 Java 的宏了。
Lightweight method objects,在 Java 7 中最终作为 MethodHandle
落地;更准确地描述可能是“一段代码”。
提到 MethodHandle
或者 MH,大多数人和互联网上的文章都会拿来和反射 API 进行比较,这里自然也拿来比较一下。
对于方法调用,反射 API 是有设计缺陷的:
getMethod
、getField
这样的操作,并不允许提供方法返回值或者字段类型作为参数进行查询 —— 分明 JVM 中存在这样的机制,更别说 JVM 允许名称和参数列表项目而返回值不同的方法多个存在了;getMethod
,什么又是 getDeclaredMethod
呢?在语言层面和 JVM 层面,只有“可访问”和“不可访问”的区别,反射 API(很长一段时间)没有区别可访问的能力;Method#invoke
的时候的装箱拆箱、可变长参数的数组创建、调用时权限检查等性能问题了。Java 7 出现的 MH 解决了上面的问题,尽管它相比反射 API 缺少了很多功能。与反射 API 相比,MH 更符合一个动态调用 API 的定位:
Lookup#findXXX
就怎么写;InvocationTargetException
;当然,MH 缺少了调用的方法名称、声明的类之类的信息,只保存了调用方法的类型,所以称之为轻量方法对象。
MH 不只是某种反射的替代品。在 JEP 160 中 JDK 引入了 LambdaForm,用于表达 MethodHandle。
LambdaForm 是一系列方法调用、若干个参数和临时变量的“一段代码”。为了简化表达,这些参数和变量只有五种类型,也就是引用类型和四个基础类型(int
、long
、float
、double
)和 void
。
以 LambdaForm 的文档为例:
(a0:J)=>{ a0 } == identity(long)(a0:I)=>{ t1:V = System.out#println(a0); void } == System.out#println(int)(a0:L)=>{ t1:V = System.out#println(a0); a0 } == identity, with printing side-effect(a0:L, a1:L)=>{ t2:L = BoundMethodHandle#argument(a0); t3:L = Class#cast(t2,a1); t3 } == invoker for identity method handle which performs cast
LambdaForm 设计为可以轻易地翻译到字节码和 JIT IR,可以看作是字节码的抽象表达形式。
Lightweight bytecode loading,可能你从来没有见过?
Java 15 包括了 JEP 371: Hidden Classes,或者说是隐藏类;而在 Java 15 之前,这样的功能通过 Unsafe#defineAnonymousClass
实现。
这个功能用于生成一些轻量的、用于调用其他方法的没有名称的类。
如果是调用方法的话,是不是和 MH 非常类似?它与 MH 最大的区别是,它是类,所以可以有字段,被用于 Lambda 表达式中的变量捕获。
Interface injection,接口注入,动态语言中通常有这样的功能,可惜 JVM 尚且没有。
这个设计的原型描述1是相对保守的,注入接口后不会影响已有类的解析和调用,但是 instanceof
和类型转换会反馈注入的接口。
运行时接口注入虽然没有被 JDK 加入,但是 DCEVM 项目(现在是 JetbrainsRuntime)可以实现这一功能。Mixin 这样的项目也可以在类加载的时候注入接口。
Continuation 就是程序后面的部分,或者说剩下的部分。
System.out.println("ザ・ワールド 時よ止まれ!");Thread.sleep(5000);System.out.println("ザ・ワールド 時は動き出す");
在执行到 Thread#sleep
时,自然最后一个 System.out.println
是当前的 Continuation。而将 Continuation 明确表示出来(比如一个 Lambda 表达式),并把它传递(passing)到其他地方的代码,就叫 continuation-passing style。
System.out.println("ザ・ワールド 時よ止まれ!");Runnable continuation = () -> System.out.println("ザ・ワールド 時は動き出す");CompletableFuture.delayedExecutor(5000, TimeUnit.MILLISECONDS).execute(continuation);
而不将 Continuation 明确表现出来,而是让 JVM 管理,就是这个项目的目标。
尽管 mlvm 项目的 continuation 并没有最终实现,但是另一个项目 loom 最终在 Java 19 实现了 JVM 上的 Continuation 和虚拟线程。
Tail calls/tail recursion,也就是尾调用、尾递归,在函数式编程中更为常见。对于尾递归,我们可以借用 Continuation 的概念,定义为“方法最后的调用的 Continuation 是其自身”。
mlvm 项目对于尾递归的实现是添加一系列新指令,比如 tailcallinvokestatic
这样。当然,这样的实现最终没有落地。
loom 项目似乎也会提供对于尾调用的支持,虽然目前还没有。
元组(Tuple),可以简单的理解为几个东西的组合,好比一个坐标 Vector3i
就是 (x: int, y: int, z: int)
这样的元组。
值类型,是相对于引用类型而言的概念,这样的类型在 JVM 中直接保存在栈上,如 int
、long
一样。
在 mlvm 项目中,值类型会被实现为类似 {LBlockPos;III}
这样的类型签名,通过 vaccess getfield
这样的指令读取字段,并且值类型是可变的。在现在看来,这样的设计自然是复杂且不好的。
值类型自然在 mlvm 项目没有落地。随后的 valhalla 项目接过了值类型的担子,并且同时准备提供泛型特化的特性。
mlvm 项目未尝不是某种伊卡洛斯的坠落,它的目标在十年后仍在进行。
虽然 Graal 项目本身的目标是用 Java 编写 compiler,却实现了 mlvm 设想的 Multi Language VM。
]]>DataFixerUpper 中的核心类是 Codec
,结合了 Encoder
和 Decoder
两个类的功能。它们都是无状态的不可变对象,用于变换不可变数据。
其中,Codec<A>
代表 A
的数据编码,本质上是两个方法,序列化和反序列化方法的组合。
// Decoder,也就是反序列化int result = parseAsInt(readAsString(decompressBytes(/* 某种数据 */)));// Encoder,也就是序列化byte[] result = compressBytes(stringToBytes(intToString(42)));
Codec
就是上面这些方法的抽象表示。
DFU 中使用 DynamicOps<T>
表示数据的序列化格式,在 Minecraft 环境下有 JsonOps 和 NbtOps。DynamicOps 的参数 T
用于表示数据的基本类型,对于 NbtOps 而言为 Tag
类。
一个 DynamicOps<T>
和一个 T
的实例,或者说 new Dynamic<T>(DynamicOps<T>, T)
,组成了反序列化的数据来源,通过 parse
和其他一系列方法进行反序列化:
DataResult<Integer> result = Codec.INT.parse(JsonOps.INSTANCE, new JsonPrimitive(42));assert result.result().get() == 42;
反序列化得到的 DataResult<A>
是反序列化的结果或者错误,分别可以用 result()
和 error()
获取。
DFU 编写 Codec 的方式大多是通过组合和变换已有的 Codec 进行的,绝大部分情况下不需要自行实现 Encoder
和 Decoder
中的方法。
Codec | Java 类型 |
---|---|
BOOL | Boolean |
BYTE | Byte |
SHORT | Short |
INT | Integer |
LONG | Long |
FLOAT | Float |
DOUBLE | Double |
STRING | String |
BYTE_BUFFER | ByteBuffer(byte[]) |
INT_STREAM | IntStream(int[]) |
LONG_STREAM | LongStream(long[]) |
除去以上的基本类型数据,如果想自定义更复杂的数据类型就需要组合了。
Record,和 Java 16 正式加入的 Records 对应,表示一个不可变的 Java 对象,以键值对编码。
public record User(String id, double balance) { static Codec<User> CODEC = RecordCodecBuilder.create(it -> it.group( Codec.STRING.fieldOf("id").forGetter(User::id), Codec.DOUBLE.fieldOf("balance").forGetter(User::balance) ).apply(it, User::new));}
如上,创建一个 Record Codec 通常通过 RecordCodecBuilder.create
进行,通过向 Instance
实例(那个 it
)提供数个对应字段的 Codec 和构造方法引用,创建对应 Codec。
为了方便,通常按照构造方法参数的顺序提供字段 Codec,这样最后调用 apply
传入的构造方法就可以直接写成方法引用的形式。
除 fieldOf
以外,还可以用 optionalFieldOf
提供一个 Optional<A>
作为参数,或者通过 optionalFieldOf
的第二个参数提供默认值。
其他的复杂数据类型,其实也就是 List<A>
和 Map<A, B>
了。
通过调用 listOf
就可以获得 Codec<A>
的列表形式 Codec<List<A>>
:
Codec<List<User>> usersCodec = User.CODEC.listOf();
通过调用 Codec.unboundedMap(Codec<A>, Codec<B>)
就可以获得一个基础的 Codec<Map<A, B>>
:
Codec<Map<Integer, User>> usersCodec = Codec.unboundedMap(Codec.STRING, User.CODEC);
DFU 还内置了 Either<A, B>
用于表示 A 或 B,Pair<A, B>
用于表示 A 和 B,可用 Codec 类下同名静态方法创建。
DFU 中表示“空”的值是 Unit
,可以用 Codec.EMPTY.codec()
获得其 Codec。
Codec 中有一类方法 Codec.dispatch
可以根据不同键名对值提供不同 Codec,Minecraft 中类似 ParticleType 中根据不同注册 ID 提供不同 Codec 即是这样实现的。
另一种创建数据 Codec 的方法是对已有的 Codec 进行变换。
对于两边互相兼容的类型,可以用 xmap
变换:
public record Bank(List<User> users) { static Codec<Bank> CODEC = User.CODEC.listOf().xmap(Bank::new, Bank::users);}
对于可能不兼容的类型,可以用 comapFlatMap
或者 flatXmap
进行变换:
Codec<UUID> UUID_CODEC = Codec.STRING.comapFlatMap(it -> { try { return DataResult.success(UUID.fromString(it)); } catch (IllegalArgumentException e) { return DataResult.error(it + " is not UUID"); }}, UUID::toString);
虽然经过组合和变换后的 Codec<A>
类型是一样的,但是不同的组合和变换方式会导致序列化后的结果不同。例如,上文 Bank 的 JSON 序列化形式可能是:
[ { "user": "zzzz", "balance": 42.0 }]
如果按照 RecordCodecBuilder
来创建 Bank Codec 的话,就可能是这样的:
{ "users": [ { "user": "zzzz", "balance": 42.0 } ]}
又比如,Minecraft 提供在 SerializableUUID.CODEC
的 UUID Codec 是用长度为 4 的 int 数组存储,而上文的则是字符串。
可以参照 https://docs.minecraftforge.net/en/1.19.x/datastorage/codecs/ 更多内容进行代码编写。
Minecraft 给一些常见的类都提供了 Codec,位于对应类的 CODEC
字段或者 ExtraCodecs
类中。
我自己也写了一个小工具,可以生成基于基础 Java 类型的 Record 类的 Codec。例如,上文 Bank 可以直接调用 TypeCodec.of(Bank.class)
获得对应 Codec。
在 DFU 中随处可见 App<F, A>
的形式,但是这个类里面没有任何方法,这是什么呢?
在 Java 中,Stream<A>
和 Optional<A>
都有 flatMap
方法,类似:
public class Optional<A> { public <B> Optional<B> flatMap(Function<A, Optional<B>> f);}public class Stream<A> { public <B> Stream<B> flatMap(Function<A, Stream<B>> f);}
仅有 flatMap
方法就可以轻松实现一个功能 flatten
,可以将类似 Optional<Optional<A>>
和 Stream<Stream<A>>
拉平为 Optional<A>
和 Stream<A>
,比如:
static <A> Optional<A> flatten(Optional<Optional<A>> optional) { return optional.flatMap(x -> x);}static <A> Stream<A> flatten(Stream<Stream<A>> stream) { return stream.flatMap(x -> x);}
自然而然,一样的代码不应该写这么多遍,所以我们会希望有一种长成这样的代码:
interface FlatMap<A> { <B> FlatMap<B> flatMap(Function<A, FlatMap<B>> f); static <A> FlatMap<A> flatten(FlatMap<? extends FlatMap<A>> flatMap) { return flatMap.flatMap(x -> x); }}class Optional<A> implements FlatMap<A> { /* ... */ }class Stream<A> implements FlatMap<A> { /* ... */ }
但是问题出现了,经过 flatten
后类型丢失了,我们实际上想要的是这种方法:
static <F extends FlatMap<?>, A> F<A> flatten(F<F<A>> flatMap) { return flatMap.flatMap(x -> x);}
但是在 Java 中不能这样做(这当然不是什么符合标准的 Java 语法),所以我们退而求其次,把这个 F 加在 FlatMap
上:
interface FlatMap<F, A> { <B> FlatMap<F, B> flatMap(Function<A, FlatMap<F, B>> f); static <F, A> FlatMap<F, A> flatten(FlatMap<F, ? extends FlatMap<F, A>> flatMap) { return flatMap.flatMap(x -> x); }}class Optional<A> implements FlatMap<Optional<?>, A> { /* ... */ }class Stream<A> implements FlatMap<Stream<?>, A> { /* ... */ }
这里 implements FlatMap<Optional<?>, ...>
使用 wildcard type 而不是 Optional<A>
是因为我们希望传入 flatten
的参数 F<F<A>>
中两个 F
的类型是一致的,而不是根据 A
的不同变化的(仔细想想这里的 F 到底表示什么)。
所以我们就能写出这样的代码:
Optional<Optional<String>> optional = /* ... */;FlatMap<Optional<?>, String> flatten = FlatMap.flatten(optional);
现在 Optional 的类型保留了,虽然返回的仍然是 FlatMap,但是只需要加上一个简单的工具方法转换一次就可以了:
static <A> Optional<A> unbox(FlatMap<Optional<?>, A> f) { return (Optional<A>) f;}Optional<String> flatten = unbox(FlatMap.flatten(optional));
最后,因为 FlatMap<F, A>
中的 F
实际上仅在编译期存在,作为类型约束的代码提示(不是吗?),所以我们可以定义一个空的类 Optional.Mu
来代表 Optional
:
class Optional<A> implements FlatMap<Optional.Mu, A> { static class Mu {} static <A> Optional<A> unbox(FlatMap<Optional.Mu, A> f) { return (Optional<A>) f; }}
像这样的代码,我们可以在 DataFixerUpper 里面找到很多。
再回头看到 FlatMap
这个接口,它表示了两个意思:
F<A>
的类型flatMap
的方法自然,我们可以单独有一个概念只表示 F<A>
,比如 —— App<F, A>
。
最终揭秘:App<F, A>
用于表示高阶类型(higher kinded type)F<A>
,这个接口是因为 Java 本身不支持高阶类型而出现的,作为一种标记使用。
在 DFU 中,它实际的签名是 App<F extends K1, A>
,其中:
K1
用于代表类似 F<_>
一样的类型,或者说一个单参数的类型构造器(type constructor),比如说 List<_>
;App<F, A>
代表用类型 A
应用在 F
这个类型构造器上,比如说 App<List, String>
代表 List<String>
;在实际使用中,通常是让类似 FlatMap 的类去继承 App 这样的类,类似:
interface FlatMap<F extends FlatMap.Mu, A> extends App<F, A> { interface Mu extends K1 {} <B> FlatMap<F, B> flatMap(Function<A, FlatMap<F, B>> f);}
同样的,DFU 也有 App2<F extends K2, A, B>
这样的类型,代表了有两个参数的高阶类型 F<A, B>
。
结束之前,我们可以通过 Scala 这个支持高阶类型的语言来看看,为什么 App
这个接口是因为 Java 不支持高阶类型而存在的(代码来自 cats):
trait FlatMap[F[_]] { def flatMap[A, B](fa: F[A])(f: A => F[B]): F[B] def flatten[A](ffa: F[F[A]]): F[A] = flatMap(ffa)(x => x)}
本文没有介绍 RecordCodecBuilder.Instance
为何物(type class),虽然已经很接近了,读者可以阅读 cats 的 Applicative 文档进行了解。
本文也没有介绍 DFU 的 optics 包和其他几个包的内容,因为序列化部分并不涉及这部分代码,读者可以阅读 Pickering, M., Gibbons, J., & Wu, N. (2017). Profunctor Optics: Modular Data Accessors 进行了解。
]]>这篇文章以 Minecraft 1.19.2,Forge 43.1.0 为例进行分析。
你可以在这里清晰明了的看到 Forge 的所有项目。
Forge 可以简单地定义为一个 Mod 加载器和一套帮助 Mod 兼容的 API。后者,也就是这套 API 并不是本文关注的重点,虽然不可避免地会讲到一些。
回想一下你是如何安装 Mod 的,就可以很容易地总结出 Mod 加载器是干什么的:把 mods
文件夹里的 JAR 文件加载到游戏里。
Forge 的开发工作依赖于一系列项目。
这是一个 Java 反编译器。
Forge 项目对 FernFlower 的修改,修复了一些怪异和错误的编译结果,并且增加了多线程反编译的支持,因此比其他(比如 Spigot)的反编译快了许多。
MCP 项目唯一的遗产。MCPConfig 用于提供包括:
在内的功能。
MCP 项目同时带有一个映射表,是社区维护的,当然现在已经停止维护了。
这个映射表导出之后,是一个 zip 压缩包,里面可以含有包含方法、字段、参数的 csv 文件。
例如,ForgeGradle 生成的 1.19.2 官方映射表格式为:
mapping-1.19.2-official.zip | fields.csv \ methods.csv
csv 文件格式类似:
searge,name,side,descm_100003_,lambda$init$1,0,m_100013_,updateList,0,
含有 searge 名,翻译名,仅客户端/服务端的信息和 Javadoc 注释。
检查一个映射表会不会破坏继承关系。
这里的破坏是什么呢?很简单,如果调用一个方法,被 remap 之后最终调用的实现不一样,继承关系就被破坏了。
考虑以下情况:
abstract class A { public void foo() { System.out.println("A"); }}interface B { void foo();}class C extends A implements B {}
此时接口 B 的 foo()
通过子类 C 的父类 A 间接实现了;而如果一个映射错误的把 A.foo()
和 B.foo()
分配了两个不一样的名字,那么调用 B.foo()
就不会正确的调用 A 中的实现,继承被破坏了。
当然这是最为复杂的一种情形,也会有把 String foo()
映射成 String toString()
这样错误覆盖了继承等等。
https://github.com/md-5/SpecialSource
一个根据某个映射表对 JAR 进行 remap 的工具。
基本上用法就是
java -jar SpecialSource.jar -i in.jar -o out.jar -m mappings.srg
这个工具是 md-5 写给 Spigot 项目的,Forge 一直用到了大概 2021 年。
同类型工具里支持格式大概是最多的,也很好用。
平替 SpecialSource 的工具。这个名字的缩写大概也是 LexManos 的恶趣味。large lols
能平替 SpecialSource 的工具还有 CadixDev 的 Vignette,Forge 似乎有过用这个的意愿,后来又换掉了。
对映射表这个概念做抽象形成的类库,也有 remap 的功能。
其实不是那么好用,比较好用的是 CadixDev 的 Lorenz。
前面的 SpecialSource 等都是对已经编译过的 .class 文件进行 remap,这个项目是对 Java 源文件进行 remap。
项目是基于 Eclipse JDT 项目开发的,基本可以算作一个修改过的编译器。
比较不好用,因为要生成一个 RangeMap 用于存储需要 remap 的符号的位置。比较好用的还是 CadixDev 那边的 Mercury,不需要生成中间文件。
一个支持 Artifact Transform 的 Gradle 插件。
什么意思呢?在 Gradle 5.5 发布之前,Gradle 并不支持对依赖进行例如反混淆的修改。这个插件提供了动态生成和修改一个依赖的功能,Forge 用来生成 MCPConfig 处理过的 MC Jar 和经过反混淆的依赖。
内部的实现方式应该是实现了一个 Repository 之类的接口。
Forge 似乎有过迁移到 Gradle Artifact Transform 这套系统上,但是没有后话了。
编译器(和运行时)修改字段和方法访问级别的工具。
唯一不好的一点就是修改了 AT 文件之后需要重新反编译。
检查两个 JAR 文件之间的字节码兼容,也就是不会有 LinkageError 和 IncompatibleClassChangeError 之类的错误,现在用来检验 AT 有效性。
可能是 Forge 为引入更多编译期字节码变换(接口注入?)做的准备。
Forge 的 Gradle 插件。
使用了上面的项目,帮助开发者引入反编译反混淆后的 Minecraft 依赖。
引导 modlauncher 的前置项目,基本上就是把 modlauncher 需要的依赖全部加载成 Java 模块,然后调用 modlauncher 主类。
提供了多个功能的类库。
提供 JAR 签名验证支持。Java 自带的 zipfs 不支持 JAR 签名验证(毕竟不是 jarfs),但是 cpw 等人认为需要支持这个功能。最终的实现是反射调用 JarFile 的内部签名验证实现。
为 JPMS 提供一个专用的类加载器,主要的类是 ModuleClassLoader。
提供一个 UnionFileSystem,可以把多个文件系统组合在一起,应该就是模仿 Docker 使用的文件系统。
模组加载器,提供在类加载期进行字节码增强功能,同时也负责 Minecraft 的引导。理论上来说,ML 支持运行任何 Java 程序。
modlauncher 下一共有四个 ModuleLayer,分别是:
ILaunchPluginService
接口作为 Java SPI 使用,暴露给 Forge 自身。AccessTransformer 和 Mixin 之类的东西都是以这个实现功能。ITransformationService
、IModLocator
和 IDependencyLocator
接口使用,暴露给 Modder,可以使用这个 Layer 进行字节码增强操作。其中 PLUGIN 和 SERVICE 都只能访问自身和 BOOT 中的类,GAME 可以访问所有的类,以此进行类加载隔离。
Launch target 这个概念也是在 ML 里提供的,是一个 ILaunchHandlerService
SPI,在 Forge 中用来启动不同的环境,比如客户端、服务端、GameTest、Data Generator 的开发环境和生产环境。
ML 是在 Minecraft 1.13 Forge 工具链大更新时期诞生的项目,解决了 LaunchWrapper 不支持 Java 9 以上版本的问题。
2020 年(Minecraft 1.17)以前,ML 并没有集成 JPMS 支持,也没有对 LaunchPlugin 和 Transformer 进行隔离。后来的 SJH 项目实际上是从 ML 拆分出去的。
实现了 Jar in Jar 文件系统,用于 Forge Mod 的依赖加载。
实际上实现没有特别复杂,是基于路径进行转换再代理到 zipfs。
实现了一套基于 Nashorn JS 的字节码修改系统。
这套系统解决了 1.12 时代不小心在 coremod 类里引用 Minecraft 类的问题,因为对类加载做了严格隔离。使用这套系统的 JS 通过类加载器限制只能引用指定的几个 ASM 类和指定的几个基础 Java 包。
就是太难用了,也没有补全。
在 ModLauncher 升级到 JPMS 和几个 ModuleLayer 的隔离机制后,这套系统的初衷也不成立了。
实现了 Forge 的事件总线。
具体实现是基于 ASM 生成调用类,性能比较好。
这套系统的实现非常依赖 context classloader,如果没有设置就会有一些莫名其妙的 NoClassDefFoundError。这套系统也只能在 GAME Layer 的那个类加载器环境里使用,如果模组创建了一个子类加载器,也是不能用的。
在我看来完全可以迁移到 MethodHandles.Lookup 的 defineHiddenClass 去。
我在另一篇文章里写了一些关于服务端的实现原理。
]]>不知道你如何,但是每天早上起来看编程语言论战使人精神一振。看一些人荒废整天探讨一些 blub 语言总是那么挑动神经。
blub 语言是 PaulGraham 提出的一种假设语言,他假设不同语言之间分出优劣,而 blub 语言是位于中间的语言。
会这门语言的程序员看向更低级的语言时,会认为其缺少重要的功能;看向更高级的语言时,却不能意识到为什么其更高级,而是认为其只是多了一些不必要的花哨功能。 —— 译注
(不过,大家都只会用那些趁手的语言,为我们这样的能工巧匠量身定做的精工利器。)
不过作为那篇文章的作者还是很危险的,因为我整的那个语言可能就是你得意用的。可能下一秒,这篇博客就会挤满明火执仗的暴徒。
为了自保,也是为了保护你脆弱的自尊心,这里我就生造一个新语言,作为我面对暴徒的替身。
保持耐心,看到最后,别错过精彩。
为一篇文章学一个新语言有些骇人听闻,所以我们用耳熟能详的举例。就假设这个东西比较像 JS,花括号,分号结尾,有 if
和 while
什么的,就像编程里的通用语。
选择 JS 并不是因为这篇文章要讲,而是因为大部分读者都能懂这些东西是什么:
function thisIsAFunction() { return "It's awesome";}
这个语言得“现代”一点,支持函数作为一等公民,你就可以这样:
// 返回一个含有满足集合中所有符合条件元素的列表function filter(collection, predicate) { var result = []; for (var i = 0; i < collection.length; i++) { if (predicate(collection[i])) result.push(collection[i]); } return result;}
这就是“高阶”函数,顾名思义,很上流也很好用。你会先在集合上面用它,很快你逐渐理解了一切,就开始到处用它。
比如测试框架:
describe("An apple", function() { it("ain't no orange", function() { expect("Apple").not.toBe("Orange"); });});
或者解析一些数据:
tokens.match(Token.LEFT_BRACKET, function(token) { // Parse a list literal... tokens.consume(Token.RIGHT_BRACKET);});
然后你就有了许多美妙的可重用轮子和应用,传递函数,调用函数,返回函数。函数盛宴!
且慢。我们的新语言有些离奇了,因为它有一个妙妙特性:
1. 每个函数都有色
每个函数 —— 匿名的或者有名字的那些 —— 要么是红的或者蓝的。函数的关键字不是一个简单的 function
,而是两个:
azure 是天蓝色,carnelian 是橙红色的玛瑙 —— 译注
blue_function doSomethingAzure() { // 这是个蓝色的函数}red_function doSomethingCarnelian() { // 而这是个红的}
这个语言不可以颜色平平,写一个函数就必须得挑个色儿。这是规矩,而且还有别的规矩你得遵守:
2. 函数的色决定如何调用它
想象“蓝色调用”和“红色调用”,就比如:
doSomethingAzure()blue;doSomethingCarnelian()red;
调用函数的时候,你就要按照颜色来调。如果错了 —— 红色的函数括号后面跟了 blue
,或者反过来,大事就会不妙:光脚走来走去就会踩上乐高,完整的乐高也会神秘消失一两个。
挺讨厌的吧?还有一个:
3. 你只能在红色函数里调用红色函数
红色函数里面可以调用蓝色的,就像这样:
red_function doSomethingCarnelian() { doSomethingAzure()blue;}
但是反过来就不行。如果你想这样:
blue_function doSomethingAzure() { doSomethingCarnelian()red;}
那你就会被请去西伯利亚一日游。
这样,写一个 filter()
就比较有挑战性了。得给函数选一个颜色,这个颜色会限制哪些函数可以传进去调用。
显而易见,让 filter()
是红色的比较好,这样红蓝来者不拒。
但是下一个规矩,就开始像领子里的头发丝儿一样让你痒痒了:
4. 红色的函数调用起来更加痛苦
痛苦究竟是什么先按下不提,可以先暂且想象成每个程序员写了任何函数都必须写上八百字的文档。
可能红色的函数非常冗长,又或许在某些代码块里不能调用红色的函数,还可能你只能在行数为质数的时候才能调用。这其中的重点是,如果你把一个函数弄成了红色,用了你写的东西的程序员都想给你的咖啡里加醋,或者往披萨上放煮熟的草莓。
显而易见,这样的话最好就永远不要用红色的函数了。我们又重回了理智的世界,所有函数都是蓝色,和它们都没有颜色一样,我们的新语言也不会那么蠢。
可惜,语言设计者抽风了 —— 而且众所周知所有语言设计者都喜欢调教用户,不是吗?最终的致命一击:
5. 标准库里有些函数是红的
这个语言里面有一些函数,我们必须去用的,不能自己写一套的,是红色。此时,正常人大概会认为这个语言不想让他用。
问题是不是出在高阶函数上呢?如果现在就停止尽享函数奢华,写一写简单的初等函数,想必头发也会少掉很多吧。
我们在只调用蓝色函数的时候,就把函数作为蓝色的,否则就是红色的。只要不写一些接受函数的函数,我们就不需要考虑什么函数的“颜色多态”(多态?)之类的东西。
遗憾的是,高阶函数只是一个例子而已。每当你想要把代码拆开重用时,这个问题总会像大蟒蛇一样缠上你。
再举个例子,假设我们有一些代码,实现了 Dijkstra 算法用来处理你的社交关系图(这到底有什么用?)。很快,你就得在别的地方用这些代码了,所以一个新的函数诞生了。
它是什么色?当然是蓝色比较好,但是如果它调用了标准库的红色函数呢?如果新的调用点也是蓝色的?你就得把它改写成红色了。当然还有它的上层调用。无论如何,你总是在想着颜色,颜色仿佛成了鞋里的石子儿。
显而易见,这篇文章不是真的在讲颜色。这只是个比方,文字上的小把戏。土匪斗恶霸,可不是真的在讲土匪斗恶霸。
现在,机灵的读者可能已经有点感觉了,但你或许还蒙在鼓里,那么现在就进行大揭秘:
红色函数就是异步函数
如果你在 Node.js 上写代码,每当你写出一个通过调用回调(callback)返回值的函数,你就制造了一个红色函数。回头看看那五条规则,解释一下那些比喻:
同步函数返回值,异步函数调用回调;
蓝色函数直接调用获得值,红色函数需要提供一个回调;
你不能在同步函数里调用异步函数,因为直到异步函数完成之前你不知道返回的值;
异步函数和表达式不搭,因为它们不返回值,错误处理方式也不同,所以不能在 try/catch
和大量其他控制语句中使用;
Node 的标准库充满了异步函数(虽然他们意识到了这点开始加入 ___Sync()
的同样版本)。
人们提及“回调地狱”时,他们实际上在抱怨某个语言里的红色函数。当人们创建了 4,089 个库用于异步编程,他们实际上在应付一个语言强加给的问题。
2021/12/03: 至今已有 1,5118 个异步库
Node 社区的人们意识到了回调之痛苦,于是他们发明了 Promise,或许你已经通过另一个名字学习过了 —— Future。
Promise 本质上是一个回调和一个错误处理的包装。如果给一个函数传递回调和错误处理器是一个概念,那么 Promise 就是这个概念的 特化。它是一个表示异步操作的头等对象。
听了我这段话你可能觉得 Promise 是个好东西了,其实不然。Promise 确实可以让你的编写体验提升一点,第四条规则不会那么严重。但老实说,其实就是肚子下面一点儿的地方和肚子被来上一拳的区别,可能确实不那么难受,但是应该没人会对此感到满足。
异常处理和其他控制语句仍然是不可用的状态,你也不能在同步函数里调用一个返回 Future 的函数。就算能,之后维护这段代码的人也会穿越回来对你使用蓄意冲拳。
Promise 的世界仍然被分为红蓝两色,所以就算你的语言提供了 Promise 或者 Future 这样的特性,它仍然如同我们假设的新语言一样糟糕。
(这甚至包括本人正在使用的 Dart,因此我对于能解决这一点的 fletch 相当期待。)
Dart 的 fletch 计划可以提供用户态线程,但已经流产,上方提供的链接是第三方 fork 的远古遗迹 —— 译注
C# 程序员现在大概跃跃欲试了(他们在微软提供的越来越多语法糖中不断沉陷),因为可以用 await
关键字调用一个异步函数。
这个东西可以让你调用异步函数和同步函数一样简单,只需要简单地加上一个小小的关键字。表达式里的 await
调用可以嵌套,也可以用在异常处理代码和控制流里。想必和你刚开始学习高阶函数一样,await
也会被到处使用起来。
Async-await 是好的,所以 Dart 里也有。编写异步代码变得简单多了,但是——如你所想的但是——世界仍然是红蓝两半的。尽管更加容易编写,但仍然是异步函数。Async-await 解决了四号规则,红色的函数调用不再那么痛苦,但是到此为止了:
同步函数返回值,异步函数返回 Task<T>
(在 Dart 中是 Future<T>
)包装起来的值;
同步函数直接调用,异步需要一个 await
;
当你实际上想要 T
的时候,异步方法却返回了包装的值,而且除非把调用的函数也变成异步的,否则你不能拆出实际的 T
(不过见下);
除了多出来的 await
,至少这个问题被解决了;
C# 的核心库比较古老,没有那么多问题。
变好了点儿,至少相对于单纯的回调来说,但是认为一切都完全解决了则是在骗自己。一旦我们开始接触高阶函数,或者尝试着重用代码,颜色的身影就会频频出现。
所以,JS, Dart, C# 和 Python 都有这个问题。CoffeeScript 和其他编译到 JS 的语言也有(所以 Dart 也有)。甚至 ClojureScript 都会有,尽管他们用 core.async 尽力避免了。
想知道没有的吗?Java,想不到吧。你有多久没说过“Java 这方面做的不错”了?但是事实如此。可惜 Java 也在试着转移到 Future 和异步 IO 去,仿佛在争倒数第一。
C# 本可以绕过这个问题,不过他们选择了颜色。在 C# 加入 async-await 和 Task<T>
这样的东西之前,你只需要普通的用一些同步的 API。还有一些语言没有颜色:Go, Lua 与 Ruby。
有何共同之处呢?
线程。准确的说,是多个互相独立的调用栈来回切换。它们不需要严格是操作系统线程。Go 的 Goroutine,Lua 的 coroutine 或者 Ruby 的 fiber 都很好。
接上,所以 C# 可以通过使用线程避免 async 的问题。
底层的实际问题可以表述为“该如何在异步操作完成时,回到上一次代码执行的地方”。
在堆着巨大调用栈时调用了 IO 操作的函数,而为了性能,这个函数用了操作系统底层的异步 API。你不可以等待其完成,因为它是异步的。你必须从这个调用栈返回到语言的事件循环里,让操作系统有点时间完成 IO 操作。
操作完成后,你需要回到上次调用 IO 操作的地方。一般来说,语言“记住它执行到哪里”的方式是调用栈(callstack),追踪当前正在执行的一系列函数和指令的指针位置。
但是异步 IO 就必须完整的回退(unwind)并丢弃掉整个调用栈,这似乎就和上面的需求矛盾了:只要我们不在意 IO 的结果,IO 就可以飞快!每个支持异步 IO 的语言 —— JS 则是浏览器的事件循环 —— 都某种程度上受此影响。
Node 把所谓的调用帧栈用嵌套的回调闭包(closure)实现。你在写下这样的代码时:
function makeSundae(callback) { scoopIceCream(function (iceCream) { warmUpCaramel(function (caramel) { callback(pourOnIceCream(iceCream, caramel)); }); });}
每个函数“闭合”了所有的上下文,iceCream
和 caramel
这样的参数就从调用栈上进入了堆中。外层的函数返回,调用栈销毁后,这些数据仍然四散在堆里。
问题是你得手动去做这些事情,而这个步骤实际上有一个名字:continuation-passing
style,在七十年代时有人发明其作为编译器内部使用。它可以作为一种更容易被编译器优化的代码表示方式。
不会有任何人想要像这样写代码,但是 Node 偏偏就是这样,将程序员变成了编译器后端。怎么回事呢?
Promise 和 Future 同样没有解决这些问题,你仍然在手写很多的函数字面量,区别仅仅是这些函数传入了 .then()
而不是直接写出来。
本文写作时(2015/02)包含 async await 的 ES6 尚未发布(2015/06) —— 译注
Async-await 确实有点用。如果你开了编译器的瓢,就能在里面看到 CPS 变换。C# 中使用 await
就是为了告诉编译器:“在这里拆分函数”,在 await
之后的部分由编译器合成一个新的函数。
因此,.NET 框架的 async-await 不需要运行时的支持:编译器将其编译为一系列运行时可以处理的闭包。(并且,闭包实际上也不需要运行时支持,它们被编译成了匿名类。C# 中的闭包是名副其实的穷人的对象)。
提到生成器(generator),你会想到什么吗?如果某个语言有 yield
关键字,那说不定它也可以实现类似的事情。
(实际上,我认为生成器和 async await 是同构的。我的硬盘角落里有一些呆了很久的代码,实现了一个只用了 async-await 的生成器风格游戏循环。)
回到正题,使用回调、Promise、async-await 和生成器,最终得到的结果就是一堆异步函数,包裹在一堆闭包里,散落在堆中。
这些函数的最外层由运行时管理,在事件循环或者 IO 操作完成后,调用函数回到之前返回的地方。但是在此之前,你还是得先返回一次,也就是说回退整个栈。
这就是为什么“红色的函数只能由红色函数调用”,因为你需要一路保存调用栈直到最上层的 main()
或者事件循环里。
但是如果可以用线程(操作系统或者绿色线程)的话,这些就不会是问题:挂起整个线程不需要让所有的函数返回。
我认为 Go 在这方面做的最优雅,遇上 IO 操作时直接挂起 goroutine 并恢复另一个没有被 IO 阻塞的。
而在标准库中,这些 IO 操作看起来就是同步的,也就是说,它们在完成时直接返回值。但这又与 JS 中的同步不同,因为其他的代码可以同时运行。Go 只是消除了同步和异步代码的区别。
Go 的并发编程中,你可以自行决定如何编程,而无需受到颜色的束缚。前文提到的五条规则在此完整而彻底地被消除了。
所以,下次你再因为某个语言有优雅的异步 API 而向我传教时,发现我在咬牙切齿也就了然了。你这是回到了红色和蓝色的世界。
]]>Minecraft 是一个 3D 建造沙盒游戏,发行内容包含一个单独的服务端程序可供多人联机游玩。Minecraft 服务器使用单线程运行,一次完整的逻辑循环称为一个游戏刻。
正常情况下服务器每秒运行 20 次逻辑循环,即以 20 TPS(Ticks per second) 运行。当性能达到瓶颈时,服务器难以在 50 毫秒内处理一次游戏刻,客户端将会体验到服务器出现明显卡顿,服务器性能下降的数值体现为 TPS 低于 20。通常情况下,服务器的计算任务与客户端连接数量成线性关系,由于服务器上几乎所有逻辑都运行于单个线程,其性能取决于处理器的运算能力,因此单个服务器能够为客户端提供良好服务的数量受到单个处理器性能限制。一般认为,单个服务器最多能为数十个客户端提供服务。
目前来说,提升服务器处理能力有三种方法。第一种可靠的方法是优化代码以减少计算量,以 Spigot、Paper 等项目为代表,但这种方法并没有改变服务器的单线程模型,因此伸缩性仍然较差。第二种可靠的方法是通过转发软件如 BungeeCord 进行负载均衡,但这种方法降低了客户端的服务体验,对于同一场景下多个客户端的场景仍然不能提供良好服务,但这一方法可以让单个负载均衡集群处理高达数万个客户端连接。第三种方法是改变服务器的处理模型,引入并行化处理,其中 Aikar 提出了一种被动拆分的并行化模型56,但该模型的最小并行粒度是区块,并暴露了线程安全问题给插件开发者;部分服务端实现了并行的光照计算7、异步实体寻路计算、多个维度并行计算8,但都未达到生产环境可用,并且对第三方注入的代码(插件及模组)做出了能够正确处理并行的假设,而这一点通常是无法达到的。
并行化服务器由于以下原因变得十分有挑战性。
shouldSignal
),其他部分代码也有类似情形。为解决这些问题,本文将包含以下内容。
在这一节内,我们将分析 Minecraft 服务器的计算任务架构,并提出我们的并行化运行模型。
如同大部分游戏服务器,Minecraft 服务器使用单线程运行,一次完整的逻辑循环称为一个游戏刻。
在一个游戏刻内,服务器会按顺序更新(tick)每个世界及其中的每个区块,而区块通常被认为是更新的最小单位。区块更新包含了对于实体的更新和对于方块的更新,其中实体更新包括 AI 计算、寻路、移动和碰撞等,方块更新包括计划刻更新、随机刻更新和方块实体更新,这几个内容组成了多数服务器的主要计算任务。
分析几个主要的计算任务之后不难得出,对于单个对象如实体而言,其自身的计算任务是有依赖关系的,因此我们不能首先计算移动再计算寻路;而对于不同对象之间,尽管它们之间可能会互相影响,如两个 TNT 实体的爆炸顺序会导致不同的结果,但对于 Minecraft 而言,并没有明确定义的依赖关系:对于实体更新列表使用哈希表存储,方块更新顺序使用哈希表存储因此表现为随机9。因此,我们可以将不同对象的更新并行化,同时保留单个对象更新任务的顺序。
对于单个对象不同阶段的更新任务而言,我们可以分析其对于服务器内存数据这一资源的访问模式。大部分更新任务都具有良好的“空间局部性”,例如对于实体碰撞而言,更新中只会读取附近一小部分的实体并修改它们的速度值。同时,大部分更新任务需要读写的部分具有不同的类型,如实体寻路会读取世界的方块数据并写入自身实体的目标寻路数据。因此,对于不同的更新任务,我们可以在空间上使用锁保证其执行的正确性,同时对不同的读写类型分别进行细粒度锁定。
我们接下来提出一个基于以上分析得出的模型。
对于每个更新任务,我们定义区域(Extent)用于描述其对于资源的使用属性。每个区域 E 描述了其类型 T 、其范围形状 R 和其是否互斥 F,F 为 S (Share) 共享或 X (Exclusive) 独占。为简化模型,我们将形状 R 定义为正方体,记作 [l,x,y,z]:r
,例如对于世界 1 坐标 0,0,0 处附近 16 格的范围记作 [1,0,0,0]:16
,其为从 -16,-16,-16 到 16,16,16 的 32x32x32 正方体。
我们接下来定义类型 T 的树形结构。由前分析得知,不同的更新任务可以读写分离的不同部分世界数据,如实体寻路读取方块数据写入实体数据。同时,某些类型的更新任务可能包含全局状态,因此我们可以得出一个类型的树形结构,以 GLOBAL
为根。
GLOBAL | LEVEL ___/ \___ / \ BLOCK ENTITY |BLOCK_ENTITY
对于任意一个类型 T,T 包含了所有子类型。对于这一类型树,可以拓展其以获得更细的粒度。
接下来我们定义区域 E 的重叠。对于区域 E1{T1,R1,F1}
和 E2{T1,R1,F1}
,有以下规则:
GLOBAL
或 T2 为 GLOBAL
,则重叠;l
不相等,则不重叠;F1 == F2 == S
,则不重叠;max{abs(x1-x2), abs(y1-y2), abs(z1-z2)}
)小于 R1 R2 的 r 之和,则重叠。每个更新任务由一系列区域 E 组成,当两个更新任务的每个区域互不重叠,则它们不重叠。重叠的更新任务不能并发进行。至此,我们的模型与一个事务数据库相似,但仍有一定不同。
当一个线程会请求对资源的独占控制,且已经占有资源后再次请求其他资源独占,且除了中断(Abort)以外无法释放资源时,死锁将会发生10。对于我们的模型,可能会包含嵌套的更新任务,这三个条件均可以满足,因此会发生死锁。
在事务性数据库中,事务可以被中断,因此可以使用类似 2PL 的技术,在发生死锁时中断某个事务的执行进行重试11。我们的模型不同之处在于,其更新任务无法被中断。同时,对于一个更新任务而言,一定需要对一个区域的独占访问以保证执行的正确性。因此,我们需要破坏“已经占有资源后再次请求其他资源独占”这一死锁发生的条件。
常见的防止发生死锁的方法有几种,分别是串行化执行、一次性请求所有资源、抢占式资源占有和对资源排序1012。串行化执行完全抛弃了并发,与本文的目标不符,因此不作考虑。抢占式的资源需要修改已有代码以支持对一个更新任务的中断,同时不对拓展开放,因此不作考虑。在本模型中,难以对三维空间中的范围这一资源进行排序,因此不作考虑。
本模型使用一次性请求所有资源进行死锁避免,因此需要要求所有的更新任务提供其占有资源的范围,即一系列区域。在每个更新任务开始前,对这一系列区域进行锁定。如果无法在任务开始前确定占有资源的范围,则进行 GLOBAL
的锁定。
本节将会介绍以上提出的模型的实现方式。
本模型需要对不能并行的更新任务进行正确的约束,因此需要一个对区域进行锁定的实现。我们在这里提出一种“无锁”的区域锁(ExtentLock)实现,并且支持 lock 和 tryLock 两种锁定方式。这里的无锁指多个线程可以并发地查询、插入该区域锁对象,而不需要对区域锁对象本身进行互斥访问。
因为我们需要满足一次性请求所有资源,因此所有的锁定操作都接受一系列区域作为参数。
区域锁将会有三种基本的操作:锁定、尝试锁定和释放。锁定操作将会阻塞至成功锁定对应区域;尝试锁定将会尝试锁定区域,并在存在重叠的区域时立刻返回失败;释放将会释放一个已经锁定的区域。
区域锁将使用一个线程安全队列实现,满足其锁定的发生顺序。
对于锁定操作,我们希望 1) 对重叠的区域释放的操作发生在返回之前,同时 2) 对于两个并行发生并且互相互斥的锁定操作,我们希望先成功的锁定操作对应的释放操作发生在后成功的锁定操作之前。对于这样的语义,我们可以使用 CountDownLatch
保证发生的先后顺序:CountDownLatch 的 countDown 操作发生在 await 操作返回之前。因此锁定操作的实现如下:
对于尝试锁定操作,其实现与锁定类似,但在重叠时不进行等待而直接返回:
对于释放操作的实现如下:
对于该实现,锁定返回之前等待了之前锁定所有区域的释放,因此满足了第一个条件;插入的先后顺序由队列的实现保证,而插入后检查了插入前的所有区域,因此满足了第二个条件,故该实现正确。
在具体执行游戏对象的更新时,我们将需要并行化的更新任务投入线程池,并等待所有任务完成。大部分耗时且有并行可能的任务通常由一个循环开始,对于该循环,我们将其中的每个元素更新的调用和该更新任务的描述(即其一系列区域)提交至线程池,使用区域锁进行互斥保护,并等待线程池中所有任务执行完成。在等待并行执行时,主线程被挂起。
前文提到了我们可以对同一个对象的更新任务进行阶段的切分。切分的主要目的是在完成某个任务后释放其占有的资源,并等待申请新资源的锁。切分的实现可参考 Minecraft 对于 Profiler 的实现方式,在每个阶段切换时通过一个 popPush
调用,完成对原有资源的释放和新资源的锁定。该方法对于代码的修改较小,实现为插入一条方法调用,通常不会影响类似 Mixin
等字节码操作框架的执行。
在对任务切分并提交至线程池后,我们的目标变为将这一系列任务的执行以最短的时间完成。该目标与一个事务型数据库的事务处理相似,需调度一系列存在竞争的任务。对这一方面的研究称为竞争感知调度1314(Contention aware scheduling),但与一般的事务型数据库不同的是,本模型既不能中断事务也不能部分分配一个锁,因此大部分适用于事务数据库的优化不适用此处。同时,我们不在意尾延迟(tail latency)、平均事务延迟等一般事务数据库需要考虑的指标,唯一的目标是最大化吞吐量,即最小化总延迟。
对于任务的调度,本模型采用在每个阶段开始之前挂起线程后,通过调度器编程进行指定任务的线程启动。同时对于支持协程15的 JVM 而言,可以在更新任务切换阶段时挂起该协程,进行任务的调度。理论上而言,协程并不是一个必须的技术,因为对于线程模型而言,我们也可以挂起线程进行调度,但达到相同的调度效率需要启动与调度集大小相同的线程数,这可能是数千,而几千个线程将消耗数 GB 的内存。
对于本模型的任务,我们可以使用一种启发式算法进行调度。每个更新任务可以提供其将要占用的资源,即一系列区域。我们引入竞争度优先调度,利用前文定义的更新任务重叠,定义一个更新任务的竞争度为该任务与更新任务队列中参与调度所有任务的重叠数量。对于所有任务,优先执行竞争度最大的。该启发式算法对于仅有互斥锁的情况下可降低一半以上的调度时间消耗,对于存在共享锁的情况优化能力稍弱,但相比原始实现仍有显著提升。
我们也进行了对于该调度问题的其他算法尝试。一个尝试是进行 k-means 聚类16后按 cluster 进行调度,相比于原始算法有所提升,但弱于竞争度优先算法。另一个尝试是实现了一个一般的事务型数据库基于图的调度器,但在对于区域切分时遇到困难,同时难以实现一次性分配所有锁的同时对锁进行调度。
本模型中存在这样一种情况,在一个更新任务运行时会启动另一个任务,即存在任务的嵌套。对于一般的线程池而言,其遵循一个先进先出的队列模型,而对于嵌套的任务,其完成发生在上一级任务完成之前,即遵循后进先出的栈模型。
对于嵌套的任务执行,一种解决方法是立刻执行,不向线程池提交,但这种方法在一种情况下会降低并行性,即当前任务可以生成多个嵌套任务时;另一种方法是向线程池提交,但 Java 默认的线程池实现将会使得一个线程等待另一个线程完成,对于固定大小线程池可能会导致死锁,也可能造成运行时产生过多线程数影响性能。如果提高并行性,我们就需要在原来任务等待时执行嵌套任务,执行完嵌套任务后执行原来任务省下的部分1718。
对于这种情况通常有三种方式实现。第一种方式是类似异步函数一般直接返回,但这种方法需要对代码进行转换,因此不作考虑。第二种方法是在等待时直接执行其他任务,这种方法需要充足的栈空间。第三种方法是将栈直接保存,重新执行其他任务后恢复原来的栈继续执行,这种方法需要 JVM 支持。
本文介绍 Minecraft 实体相关的目标选择器(Goal Selector)、脑(Brain)等内容,基于 Minecraft 1.18 版本,使用官方映射表。
如果一款游戏里面的生物不会对玩家的行为产生反馈,那就太无聊了。想象这样的场景,NPC 对经过的玩家熟视无睹,和玩家对话时机械地盯着虚空…
在 Minecraft 里,玩家可以很容易地将实体分为两类:无感知的实体,比如船;和有感知的「生物」,比如羊。
回顾一下,羊会干什么呢?
和大部分生物一样,羊会望向接近的玩家;羊也会跟着拿着小麦的玩家,还会吃草…将这些能力一一组合起来,便成为了一个「有感知」的生物。而将这些东西组合在一起的系统,被称为目标选择器。
望向玩家、吃草、跟随玩家都是目标(Goal
),它们以不同的优先度出现在目标选择器中。对于一个普通的羊,它会有这些目标:
Priority | Goal |
---|---|
0 | FloatGoal(浮在水面上) |
1 | PanicGoal |
2 | BreedGoal |
3 | TemptGoal(跟随小麦) |
4 | FollowParentGoal |
5 | EatBlockGoal(吃草) |
6 | WaterAvoidingRandomStrollGoal |
7 | LookAtPlayerGoal(望向玩家) |
8 | RandomLookAroundGoal |
这些目标以某种形式共同工作:毕竟羊不是时时刻刻都在吃草,也不是时时刻刻都在随机乱走,必然存在一种机制管理它们何时运行,使得它们互相协调。
目光转向 Goal
类的几个方法:
package net.minecraft.world.entity.ai.goal;public abstract class Goal { public abstract boolean canUse(); public boolean canContinueToUse(); public void start(); public void stop(); public void tick(); public boolean isInterruptable(); public void setFlags(EnumSet<Goal.Flag> flags); public EnumSet<Goal.Flag> getFlags(); public enum Flag { MOVE, LOOK, JUMP, TARGET }}
前五个方法顾名思义,按照这样的流程运行:
图中黑圈为未运行的 Goal,红圈为正在运行的。以 TemptGoal
为例,这个 Goal 会这样编写:
canUse
进行开始条件的判断:寻找附近拿着小麦的玩家。为了性能考虑,这里还可以尽量的缓存一些需要查询的东西,比如对应的玩家、坐标…等等start
执行目标开始的逻辑,当然也可以什么都不干。tick
每个游戏刻更新目标,比如让羊向玩家走一步。canContinueToUse
检查是否仍然应该继续执行:玩家可能走远了,或者把小麦收起来了,或者有新的玩家拿着小麦走来了。stop
执行停止的逻辑,这里应该把一开始缓存的东西全部重置掉。读者可以参考许多源码进一步加深 Goal 生命周期的理解。
我们知道如果羊被玩家打了一下,会到处跑来跑去,此时玩家拿出小麦的话,合理的现象应该是羊还会继续跑来跑去。从上面的表中可以得知,PanicGoal
的优先级高于 TemptGoal
,这很合理。
但这引起了一个新的问题,FloatGoal
(让生物浮在水面上)的优先度是最高的,但是玩家还是可以用小麦去吸引浮在水面的羊。实际上,这是一个比单纯的优先度更为复杂的机制。
每个 Goal 可能会有一些 Flag
,或者把它叫做锁。上面的例子里,FloatGoal
有 JUMP
的锁,PanicGoal
和 TemptGoal
都有 MOVE
的锁。这些锁是「互斥」的,也就是说,同一时间某一种 Flag 只会对应一个 Goal。自然,不持有任何锁的 Goal 不会与其他 Goal 互斥,它们不受影响始终执行。
那么如果同时有两个 canUse
的 Goal 都有 MOVE
呢?显而易见,优先度高(数字小)的执行。但这又带来了新的问题:如果玩家拿着小麦打了一下羊,优先度高的 Goal 突然可用了,优先度低的跟随小麦会怎样呢?当然就应该中断了。
回到 Goal
类的后三个方法:
isInterruptable
表明一个 Goal 能否被其他 Goal 中断setFlags
用来设置 Goal Flag,一般在构造方法中调用在 Minecraft 1.18 中,Mojang 引入了一个小优化:观察到通常 canUse
执行最多的代码逻辑来判断和缓存状态,但未运行的 Goal 并不影响实体,并且让羊慢一两刻再对玩家的小麦进行响应通常不会破坏游戏的沉浸感。
优化的方法是,每 N 刻中只有最后一刻进行 canUse
/canContinueToUse
检查,其他 N-1 刻只进行 tick
的调用。在 1.18 这个 N 是 2,但也不排除以后会增加,所以不要对这个 N 的具体数值做假设。
回到 Goal
类,1.18 起提供了以下两个方法:
public abstract class Goal { public boolean requiresUpdateEveryTick(); protected int adjustedTickDelay(int tick);}
其中:
requiresUpdateEveryTick
让这个 Goal 每一刻都更新,可以用于实时性高的情景adjustedTickDelay
相当于 tick/N
考虑 PanicGoal
,如果我们想让羊到处乱跑十秒钟,就会这么写(并非 Mojang 实现):
public class PanicGoal extends Goal { private int ticks; @Override public void start() { this.ticks = adjustedTickDelay(10 * 20); // 10s } @Override public boolean canContinueToUse() { return this.ticks-- > 0; }}
实际在 Minecraft 中提供了两个 GoalSelector,另一个用于攻击目标的选择。对于有攻击性的生物,一般在 goalSelector
中注册一个 MeleeAttackGoal
(近战攻击)或者 RangedAttackGoal
(远程攻击),而在 targetSelector
中注册选择某个敌人进行攻击的 Goal,比如 HurtByTargetGoal
(攻击生物的敌人)或者 NearestAttackableTargetGoal
(最近的敌人)。
目标通常在 Mob#registerGoals
方法内注册,考虑烈焰人:
public class Blaze extends Mob { protected void registerGoals() { this.goalSelector.addGoal(4, new Blaze.BlazeAttackGoal(this)); this.goalSelector.addGoal(5, new MoveTowardsRestrictionGoal(this, 1.0D)); this.goalSelector.addGoal(7, new WaterAvoidingRandomStrollGoal(this, 1.0D, 0.0F)); this.goalSelector.addGoal(8, new LookAtPlayerGoal(this, Player.class, 8.0F)); this.goalSelector.addGoal(8, new RandomLookAroundGoal(this)); this.targetSelector.addGoal(1, (new HurtByTargetGoal(this)).setAlertOthers()); this.targetSelector.addGoal(2, new NearestAttackableTargetGoal<>(this, Player.class, true)); }}
回顾 GoalSelector 系统,我们有什么呢:
为解决这些痛点,村民更新后出现了一套新的系统:Brain、Sensor(知觉)、Memory(记忆)、Behavior(行为)、Activity(活动)、Schedule(计划)。
核心自然是 Brain 了,它负责存储记忆,按照计划管理活动和行为的运行停止,时不时感知环境变化:
可见,一个行为被激活的要求变得更多了:需要有对应的记忆,也需要正在进行对应的活动。
Memory,也就是记忆,补齐了 Goal 系统中最大的不足:没有持久化。
想象一下,假如你是一个铁匠,一个玩家不死人打了你一下,如果退出重进之后你就忘了,那也太便宜他了。
public class MemoryModuleType<U> { public MemoryModuleType(Optional<Codec<U>> codec);}public class Brain<E extends LivingEntity> { public boolean hasMemoryValue(MemoryModuleType<?> type); public <U> void eraseMemory(MemoryModuleType<U> type); public <U> void setMemory(MemoryModuleType<U> type, @Nullable U value); public <U> void setMemoryWithExpiry(MemoryModuleType<U> type, U value, long ticks); public <U> Optional<U> getMemory(MemoryModuleType<U> type);}
记忆可以被持久化,需要向 MemoryModuleType
的构造方法中传入对应的 Codec
。Codec 类中带有一些基础类型的实例,对于更复杂的数据类型比如一个 Map,则可以使用 RecordCodecBuilder
进行构造。对于 Codec 和 DataFixerUpper 的具体使用方法这里不作更多介绍。
记忆也可以过期,也就是「遗忘」。只需要在写入时调用 setMemoryWithExpiry
,以刻计算。
Sensor 类似于「眼」,它们每隔一段时间进行感知,并将感知到的结果写入记忆中。
public abstract class Sensor<E extends LivingEntity> { public Sensor(int scanRate); protected abstract void doTick(ServerLevel level, E entity); public abstract Set<MemoryModuleType<?>> requires();}
唯一的一个构造参数是扫描(doTick
)的频率,按游戏刻计算。
以感知附近拿着小麦的玩家来举例:
requires
代表这个 Sensor 会写入哪些记忆,比如拿着小麦的玩家public Set<MemoryModuleType<?>> requires() { return Set.of(MemoryModuleType.TEMPTING_PLAYER);}
doTick
执行实际的逻辑,并写入记忆;如果并没有搜索到结果,则擦除记忆protected void doTick(ServerLevel level, LivingEntity entity) { var brain = entity.getBrain(); for (var player : level.players()) { if (player.getMainHandItem().getItem() == Items.WHEAT) { brain.setMemory(MemoryModuleType.TEMPTING_PLAYER, player); return; } } brain.eraseMemory(MemoryModuleType.TEMPTING_PLAYER);}
Behavior 的设计与 Goal 类似,主要有三点不同:
entryCondition
,用于判断某个记忆存在(VALUE_PRESENT
)、不存在(VALUE_ABSENT
)或者无所谓(REGISTERED
)。这样的设计分离了进入逻辑 —— 它被转移到 Sensor 了。不过在行为中自定义进入逻辑也是可以的,覆盖 checkExtraStartConditions
即可。entryCondition
满足对应的记忆要求,另一个是提供它的 Activity 正激活。[minDuration, maxDuration]
中随机的一点执行时间,超时或者不能继续进行(canStillUse
)都会导致行为停止。timedOut
方法可以重写,比如「浮在水面上」我们一般不希望让它超时。public abstract class Behavior<E extends LivingEntity> { public Behavior(Map<MemoryModuleType<?>, MemoryStatus> entryCondition, int minDuration, int maxDuration); protected void start(ServerLevel level, E entity, long gameTime); protected void tick(ServerLevel level, E entity, long gameTime); protected void stop(ServerLevel level, E entity, long gameTime); protected boolean canStillUse(ServerLevel level, E entity, long gameTime); protected boolean timedOut(long gameTime); protected boolean checkExtraStartConditions(ServerLevel level, E entity);}public enum MemoryStatus { VALUE_PRESENT, VALUE_ABSENT, REGISTERED;}
Activity 是一系列有优先度的行为,这一点与 Goal 系统很类似。
public class Brain<E extends LivingEntity> { public void setCoreActivities(Set<Activity> activities); public void useDefaultActivity(); public void setActiveActivityIfPossible(Activity activity); public void setDefaultActivity(Activity activity); public void addActivity(Activity activity, int priority, List<Behavior<E>> behaviors); public void addActivityAndRemoveMemoriesWhenStopped( Activity activity, List<Pair<Integer, Behavior<E>>> behaviors, Set<Pair<MemoryModuleType<?>, MemoryStatus>> memoryRequirements, Set<MemoryModuleType<?>> memoryToErase); public boolean isActive(Activity activity);}
Brain 中有两类活动,Core
(核心)和其他的活动。核心活动是一系列任何时候都激活的活动,一般类似「浮在水面上」这种行为就放在里面(setCoreActivities
)。其他添加的活动中,有且只有一个会被激活。Activity 类中提供了一些常用的活动,比如 IDLE
,这也是默认的活动。
类似行为,活动可以有需要满足的记忆条件(memoryRequirements
),因此 Sensor 也可以控制活动。活动也可以指定退出时自动擦除某些记忆(memoryToErase
)。如果除了核心活动以外的活动都不满足条件,则默认的活动(setDefaultActivity
)被激活。
可能与直觉不同的是,活动退出时,正在运行的行为不会结束。
Schedule 是用于按照游戏时间定时切换活动的系统。激活计划需要手动添加一个行为 UpdateActivityFromSchedule
,计划如何编写可以直接参考 Schedule 类中的代码。
村民便是使用这套计划系统来进行白天劳作、晚上睡觉。
public class Brain<E extends LivingEntity> { public void setSchedule(Schedule schedule);}
无论是 Goal 还是 Brain 系统的设计,都无法很好地处理「事件」,类似被玩家攻击的事件是通过一个每刻进行刷新的字段实现的。
如果想要很好地设计能够处理事件的 AI,可以参考这篇文章:
An event-driven behavior trees extension to facilitate non-player multi-agent coordination in video games. DOI:10.1016/j.eswa.2020.113457.
所以你知道为什么一只羊不会停下吃草抬头望着你了吗?
]]>文章信息密度较高,读者请善用搜索引擎。
在 Minecraft 支持多人模式后,玩家们发现了其面向更多用户时存在的不足。这可能是权限管理的缺失,易用性不足,或是缺少一些娱乐性的功能。
面对这些不足,开发者们自然会想到一件事:改代码,可是 Minecraft 是一款商业程序,并不向用户开放源代码,我们也不能直接发布这些代码。
可以说,这些商业程序的限制决定了 Minecraft 模组/服务端特殊的开发流程。
经过长时间的摸索,社区逐渐形成了两种主流的「改代码」方式。
较为常见的方式是,反编译 Minecraft 并在此基础上修改添加功能,代表性的项目有:
另一种方式是,在 Minecraft 服务端启动时动态地修改加载的代码,代表性的项目有:
Minecraft 1.12 及之前版本的 MinecraftForge 同时利用了这两种技术,即对反编译的源码进行修改,并在启动时加载这些修改。该项目同时自带一些「CoreMod」用于运行时修改代码,例如 AccessTransformer。
本文不提供 Minecraft 游戏文件分发,所述内容仅供学习参考。
Minecraft 作为一款商业游戏,使用了混淆技术来保护其不被轻易地盗用。我们以 1.17.1 版本为例,可以在 该链接 下载到对应的服务端文件。
打开这个 JAR 文件后,我们可以看到以下内容:
其中的文件夹是 Minecraft 引用的第三方库文件和其自身使用的资源文件,而剩余的 .class
文件即为 Minecraft 的代码文件。
现在我们遇到了第一个问题,.class
文件并不是源代码,不能直接阅读。对此,社区会使用反编译器进行代码文件的逆向工程,常用的反编译器为 FernFlower,被上文介绍的所有基于反编译修改的项目采用。
Recaf 提供了 FernFlower 的图形界面程序。
我们拿 abv.class
举例,反编译后如下:
显而易见我们遇到了第二个问题,所有的方法和字段都被混淆了,需要一种方法来将它变得人类可读。例如,上文的 abv
实际上的类名为 Ticket
。
在 Minecraft 1.14.4 以前,社区采用的办法是,根据自身的开发经验,猜出或者说给出这些类混淆前的名称。类文件中的字符串常量、继承和调用关系无不暗示着这些类本来的用途,经验丰富的开发者便可以通过这些蛛丝马迹猜出它们本来的名称。而这些从混淆后到混淆前的名称的映射,我们称之为混淆表或者映射表。
社区一共总结了三套常用的混淆表,它们分别是
自 Minecraft 1.14.4 起,微软开始公布 Minecraft 的官方混淆表,声称其能帮助 Modding 社区更好发展。官方映射已被 Arclight、Forge、Sponge 和 Paper 项目采用,Spigot 项目部分采用了官方映射的字段名用于开发。Fabric 侧允许用户使用官方表,但并非默认。
因为微软仅公布了类名、方法名与字段名的混淆数据,并未公布方法参数名称,因此 Parchment 项目成立,仅提供参数名称。
反混淆技术的具体实现细节位于反混淆的实现节。
对于使用反编译后的代码修改进行开发的项目,因为反编译器不够完善,反编译的代码通常充满错误而不能直接编译,我们还需要一个额外的步骤:手动修复反编译的错误。
对于 Forge 项目,所有的修复代码和工具提供自 MCPConfig 项目,而该项目的前身是 ModCoderPack。MCPConfig 项目包含了每个反编译源代码文件的 patch
。对于 Spigot 项目,这些修复直接包含在功能实现的 patch 中。
分发问题通常只出现在使用反编译代码进行修改的项目上。知名的 CraftBukkit 项目因为直接提供修改后的 Minecraft 代码和二进制文件而受到 DMCA Takedown 请求,随后社区对这种情形进行了规避。
一共有两种情况可能会涉及到 Minecraft 代码文件的分发:通过网络与他人共同开发一个项目,以及将这个项目最终分发给用户。社区对这两种情况都进行了规避。
在开发阶段,互联网上公开发布的是 patch
文件,包含了对源码修改的描述。patch
文件如下所示:
该文件描述了 pom.xml
文件从第一行开始后的 11 行内,应该删去哪些内容再加入哪些内容。通过 patch
文件,开发者可以避免发布整个源代码,而仅仅发布修改的片段。
patch
文件可以通过比较修改前和修改后的文件来生成,也可以用 patch
文件结合修改前的文件生成修改后的文件。
在 Minecraft 1.13 以前,Forge 模组在开发前需要输入 gradle setupDecompWorkspace
,该命令实际上便是进行了 Minecraft 文件的下载、反混淆、反编译、源码修复与应用 patch
文件。Minecraft 1.13 后 Forge 团队优化了工具链,从此开发者不需要手动运行这个任务了。
而面向最终用户,社区同样不能将他们修改过的游戏 JAR 文件直接发布。
Forge 项目与 Paper 项目选择发布一种类似 patch
文件的 binpatch
,描述了对于二进制文件的修改内容,从而避免了直接分发。
Spigot 项目选择了另一种方式,发布 BuildTools 让用户自行构建最终的 JAR 文件。BuildTools 隐藏了所有的开发环境细节,使得任何一个安装了 Java 的普通人可以通过一个命令构建出 Spigot,而他只需要等一会儿。
我们已经知道了怎么修改 Minecraft 的代码了,接下来继续关注如何实现功能,并向其他开发者开放。本文只关注服务端侧的功能。
对原版代码的修改,大部分情况下可以概括成「在某一行前执行我们的代码,修改一些变量,决定是否继续执行」,比如:
/talk
命令:在执行 /talk
命令的代码前,决定不执行接下来的代码这样的模型被归纳为事件模型。每个事件提供了在事件发生时获取上下文信息(比如执行的命令是 /talk
),执行外部代码,修改上下文和取消执行的能力。
上文介绍的几乎所有项目都采用了事件模型为第三方开发者提供拓展和修改 Minecraft 自带逻辑的 API。Fabric 项目提供了 Callback
作为 API,也可以看做另一种形式的事件。
通过提供丰富的事件,例如玩家加入服务器的事件、玩家攻击生物的事件等,各大开发框架得以更好的为第三方开发者服务。一个统一的事件同样避免了多个第三方开发者同时修改一个地方的代码而引入潜在的冲突。
对于基于反编译修改的项目来说,实现一个事件很简单:在对应的代码出写一行调用事件的代码就可以了。
而对于运行时修改的项目,情况就变得复杂一些,有两个东西必须介绍:类加载器与类修改技术。
类是如何加载的呢?以 URLClassLoader 为例,在进行类加载时,类加载器首先查找已经加载的类,如果不存在则请求父加载器加载,如果不存在则尝试自己加载。这样的加载模型叫做双亲委派,伪代码如下:
Class<?> loadClass(String name) throws ClassNotFoundException { // First, check if the class has already been loaded Class<?> c = findLoadedClass(name); if (c == null) { try { c = parent.loadClass(name); } catch (ClassNotFoundException e) { // ClassNotFoundException thrown if class not found // from the parent class loader } if (c == null) { // If still not found, then invoke findClass in order // to find the class. c = findClass(name); } } return c;}
如果我们可以在「自己加载」这一阶段,对即将加载的类文件进行一些修改,便可以达成「改代码」的目标。于是我们可以写出这样的代码:
Class<?> findClass(String name) throws ClassNotFoundException { byte[] bytes = loadFromDisk(name); if (bytes == null) { throw new ClassNotFoundException(name); } bytes = transformClass(bytes); Class<?> c = defineClass(name, bytes); return c;}
defineClass
将字节流的类文件转换为内存中可用的 Java 类,这一过程由 Java 虚拟机(JVM)执行,我们不需要知道其原理也无法干涉。自然,奥秘包含在 transformClass
方法中,至此我们终于可以开始介绍类修改技术。
尽管不像 .java
文件一样可供程序员直接阅读,.class
是有规范格式和结构的文件。本文不对类文件格式做详细介绍,仅描述我们关心的地方。
我们可以使用 javap -c abv.class
查看前文所述 abv 类的类文件内容(部分内容已省略):
public final class abv<T extends java.lang.Object> implements java.lang.Comparable<abv<?>> { ... public int a(abv<?>); Code: 0: aload_0 1: getfield #33 // Field b:I 4: aload_1 5: getfield #33 // Field b:I 8: invokestatic #47 // Method java/lang/Integer.compare:(II)I ... 62: ireturn ...
类文件中我们最关心的「逻辑」存在于方法的 Code
属性中,为操作码(Opcode)序列的形式。在反编译修改的项目中,我们说「在某一行插入我们自己的代码」,对应到类文件也就是在某个 Opcode 处加入我们自己的 Opcode 序列。
操作类文件时,我们通常使用 ASM 库解析 .class
。ASM 库有两套面向开发者的 API,通常称为 Core API 和 Tree API。Core API 使用 Visitor 模式,这种编程范式尤其适合对固定结构的数据提供多种操作。Tree API 相对易于理解,也可能更易于操作,同时性能相较 Core API 略低。
接下来两个小节将会展示对于类修改的基本理念。
前文所述,反混淆可以将 abv
变成 Ticket
,那么这是怎么做到的呢?
我们首先介绍类文件的「签名」,也即类型在 JVM 中的表示方法。
类型 | 签名 |
---|---|
boolean | Z |
byte | B |
char | C |
short | S |
int | I |
long | J |
float | F |
double | D |
java.lang.Object | Ljava/lang/Object; |
T 的数组类型 | [T |
方法 | (参数签名)返回类型签名 |
JVM 中,引用类型的内部表示(internal name)以斜线(/
)分割,签名则是在其前后加上 L
和 ;
。方法的签名则是在括号内写上所有参数签名的拼接,最后加上返回值的签名。因此,方法
String[][] foo(String s, double d, int[] array)
的类型签名为
(Ljava/lang/String;D[I)[[Ljava/lang/String;
因此,如果我们想把 abv
翻译成 net.minecraft.server.level.Ticket
,就需要将类文件中所有的 Labv;
替换为 Lnet/minecraft/server/level/Ticket;
。
同时,我们也想对字段和方法进行重命名,其原理是类似的,但是我们需要介绍几条特定的 Opcode。
对于字段,我们会有 GETFIELD
, PUTFIELD
, GETSTATIC
, PUTSTATIC
指令,分别用来读取写入普通字段和静态字段的值。例如,对于 abv
类中的 int b
字段,我们要将其命名为 ticketLevel
,则需要将所有的
GET/PUTFIELD abv b:I
翻译成
GET/PUTFIELD net/minecraft/server/level/Ticket ticketLevel:I
类似的,对于方法,我们有 INVOKEVIRTUAL
, INVOKESTATIC
等五条指令用来调用方法,将其对应地替换即可。
如果使用 ASM 库来实现这样的功能,我们可以写出这样的代码(并不完整!):
class RemapVisitor extends ClassVisitor { @Override public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) { if (name.equals("abv")) { super.visit(version, access, "net/minecraft/server/level/Ticket", signature, superName, interfaces); } else { super.visit(version, access, name, signature, superName, interfaces); } } @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { return new RemapMethodVisitor(Opcodes.ASM9, super.visitMethod(access, name, descriptor, signature, exceptions)); } class RemapMethodVisitor extends MethodVisitor { @Override public void visitFieldInsn(int opcode, String owner, String name, String descriptor) { if (owner.equals("abv") && name.equals("b")) { super.visitFieldInsn(opcode, "net/minecraft/server/level/Ticket", "ticketLevel", descriptor); } else { super.visitFieldInsn(opcode, owner, name, descriptor); } } }}
实际使用中,一般使用一个 Map 保存所有可能的映射。
对于「所有可能的映射」,社区一般使用一个文本文件进行表示,而这个文本文件衍生出了很多种格式:
TSRG
,由 Forge 项目使用SRG
及其衍生 CSRG
,由 Forge 项目和 Spigot 项目使用TINY
,由 Fabric 项目使用以 TSRG
格式举例,对于 abv
类的所有映射,有以下内容:
abv net/minecraft/server/level/Ticket a type b ticketLevel c key d createdTick <init> (Labw;ILjava/lang/Object;)V <init> a ()Labw; getType a (Labv;)I compareTo a (J)V setCreatedTick b ()I getTicketLevel b (J)Z timedOut compareTo (Ljava/lang/Object;)I compareTo equals (Ljava/lang/Object;)Z equals hashCode ()I hashCode toString ()Ljava/lang/String; toString
以上介绍的反混淆技术,包括对类文件的处理和映射表文件格式的读取,社区都提供了对应的工具。最常用的库是 SpecialSource,提供了上述所有功能的支持,被广泛的使用于除了 Fabric 的几乎所有项目中。Fabric 社区使用 tiny-remapper。Cadix Dev Team 提供了一系列用于各种字节码操作的库,Lorenz 可用于表示映射,Atlas 可用于反混淆类文件。
除了对类文件进行反混淆外,社区同样开发了对源代码进行反混淆的软件,如 Srg2Source 和 Mercury。
反混淆仅仅做了对类已有代码的重命名,在大多数情况下,这并不能满足开发者对添加功能的需要。
我们对一个常见的功能举例分析:每 Tick 执行任务。理想情况下,我们只需要在服务端的大循环的任意一个地方插入一行 MyTask.run()
就可以了。
假设服务端的循环如下(经过修改):
public class MinecraftServer { protected void runServer() { while (this.running) { long i = Util.getMillis() - this.nextTickTime; if (i > 2000L && this.nextTickTime - this.lastOverloadWarning >= 15000L) { long j = i / 50L; LOGGER.warn("Can't keep up! Is the server overloaded? Running {}ms or {} ticks behind", i, j); this.nextTickTime += j * 50L; this.lastOverloadWarning = this.nextTickTime; } this.nextTickTime += 50L; this.tickServer(this::haveTime); this.waitUntilNextTick(); } }}
我们可能就会想在 this.tickServer(this::haveTime);
前面插入自身的任务调用代码。这个插入调用的代码和上文相当类似(仍然有省略!):
class TaskVisitor extends ClassVisitor { @Override public MethodVisitor visitMethod(int access, String name, String descriptor, String signature, String[] exceptions) { MethodVisitor mv = super.visitMethod(access, name, descriptor, signature, exceptions); if (name.equals("runServer") && descriptor.equals("()V")) { return new TaskMethodVisitor(Opcodes.ASM9, mv); } else { return mv; } } static class TaskMethodVisitor extends MethodVisitor { @Override public void visitMethodInsn(int opcode, String owner, String name, String descriptor, boolean isInterface) { if (name.equals("tickServer")) { super.visitMethodInsn(Opcodes.INVOKESTATIC, "MyTask", "run", "()V"); } super.visitMethodInsn(opcode, owner, name, descriptor, isInterface); } }}
我们便出色的完成了「插入代码」的工作 —— 但是!当要插入的代码逐渐变多,为每个调用单独写两个类的工作量变的令人无法接受。社区不断有简化这些重复劳动的尝试,最终出现了一个叫 Mixin 的项目。
使用 Mixin 后,对于上文的代码,我们只需要写:
@Mixin(MinecraftServer.class)public class MinecraftServerMixin { @Inject(method = "runServer", at = @At(value = "INVOKE", target = "Lnet/minecraft/server/MinecraftServer;tickServer(Ljava/util/function/BooleanSupplier;)V")) void beforeTickServer(CallbackInfo ci) { MyTask.run(); }}
而这个代码本身可读性比两个 Visitor 类也高出不少:注入(Inject)方法 runServer,目标(target)为 MinecraftServer:tickServer
的方法调用(INVOKE)。
Mixin 起源于 LiteLoader 项目,皆由 Mumfrey 开发,随后转入 Sponge 社区并被完整采用。Sponge 的两个服务端可能是最先采用 Mixin 的大型项目。
本文无意介绍 ASM 与 Mixin 具体的使用方式和更多更复杂的类文件修改技巧。
作为一个商业程序(似乎已经说过很多遍了),Minecraft 发布时被混淆。混淆不仅使得用户很难查看它的代码,还带来了一个新的问题:不同版本之间,同一个类、同一个方法可能有不同的混淆名字。
针对这一问题,Forge 和 Fabric 不约而同地采用了一个做法:为相同的类/方法分配相同的名字(序号)。天哪,我们已经有三套名称了!它们分别是:Mojang 发布的混淆名,跨版本稳定的中间名,开发者编写代码使用的人类可读名。
在 Forge 侧,这样的三套名称分别被称为 notch name
, srg name
和 mcp name
,所有的 srg 文件位于 MCPConfig 项目;在Fabric 侧,后两套为 Intermediary 和 Yarn。
对于不同版本之间不同混淆名的类,我们需要一个工具来对它进行匹配。Forge 社区使用 DePigifier。
对于 Forge 和 Fabric,Minecraft 服务器运行时,实际使用的名称便是这一套中间名。
采取中间名作为实际运行的环境还有另一个好处,不同开发者使用不同的人类可读名开发也可以同时运行(MCP/官方映射/Yarn/?)。同时中间名带来另一个麻烦,开发环境和运行环境完全不一致,编译完成后还需要一次额外的重混淆。
至此,我们终于可以完整地总结 Forge 和 Paper 这样的反编译修改项目是如何开发的了:
notch name
的 minecraft_server.1.17.1.jar至于像 Sponge 这样的运行时修改项目,则是这样:
本人是 Arclight 服务端作者,这里使用 Arclight 举例
混合服务端究竟做了什么呢?
答案是明显的:在 B 上实现 A 的所有功能。这样功能的实现可以通过任何一种「改代码」手段来完成:Arclight 使用 Mixin,而大多数 Forge Bukkit 服务端使用反编译修改。
混合服务端还需要处理一件事情,即目标环境和原环境的映射名称可能完全不同。例如,Forge 的运行时名称是 SRG,而 Spigot 的运行时名称是 BuildData 中的名称。对于不同名称的处理可以使用上文所述反混淆/重混淆手段。
对于从原环境到目标环境的映射,我们需要额外生成一套映射表;也就是说,如果 Forge 的映射表是 notch->srg,而 Spigot 是 notch->spigot,我们需要生成 spigot->srg 的映射。而由于 Java 中存在返回类型协变、方法参数协变逆变、接口方法由实现类的父类方法隐性实现等等因素,导致生成这样的映射表并不是一件简单的事。
Lorenz 库可以合并两个映射表并输出,尽管其并不完善。目前来说,可能只有 arclight-gradle-plugin 有能力正确地生成这样的映射表。
尽管如此,上述手段无法处理一种情况,也就是 Java 的反射技术。
Bukkit API 的目标是用其提供的版本无关接口,完成所有对 Minecraft 服务器的控制,但是总有或多或少的理由,开发者绕过这套 API,去直接调用 Minecraft 本身的代码。
而由于 Spigot 系列服务端会在构建时将 Minecraft 自带类的包名混淆成如同 net.minecraft.server.v1_17_R1
的形式,并且每个版本都不同,所以大部分开发者逐渐总结了一套「接触」Minecraft 原生代码的方法:
net.minecraft.server.v1_17_R1
这样的包名v1_17_R1
"net.minecraft.server." + version + ".EntityPlayer"
从而让一套代码可以在多个 Spigot 版本下使用。
这样的方法对于开发者自然是非常方便的,但是对于混合服务端开发者来说,处理这些代码让其正确执行就不大容易了。
Arclight 将所有反射调用进行了重定向。考虑
abv net/minecraft/server/level/Ticket f_9421_ ticketLevel
就能写出这样的代码:
static Field redirectGetField(Class cl, String name) { if (cl.getName().equals("net.minecraft.server.level.Ticket")) { if (name.equals("ticketLevel")) { name = "f_9421_"; } } return cl.getField(name);}
同时在对应的 Field#getName
调用再转换回原本的名称就可以了。
static String redirectGetName(Field f) { if (f.getDeclaringClass().getName().equals("net.minecraft.server.level.Ticket")) { if (f.getName().equals("f_9421_")) { return "ticketLevel"; } } return f.getName();}
同样的方法可以适用于类和方法的相关反射调用。
运行时重混淆技术还有更多有趣的话题:
Arclight 都有对应的处理,文章在此按下不表,可以参考 Arclight 的源码。
如果有需求可以找我要个 token。
顺便把本博客套了一层 Cloudflare。
]]>本文采用 Minecraft 1.15.2,MCP 的 20200904-1.15.1
映射表写就。
让我们开始吧。
有一日,一名玩家百无聊赖下,在 Minecraft 中开始了一次新的冒险,于是万物伊始。
这个界面,是新的区块加载方式最为直观的体现,稍后我们就会讲解这些颜色对应着什么。
在 Minecraft 1.14 中,Mojang 引入了 Ticket 用于管理区块是否、何时加载,而一个 Ticket 由以下属性组成:
net.minecraft.world.server.TicketType
,用于表示这个 Ticket 的种类;种类指定了它的存活时间 lifespan
,单位是游戏刻;level
,用于加载周围区块的距离。相信这篇文章的读者,对于区块加载可能有一个感性的认识。
出生点的区块总是加载,而且范围较大,可能有十多个区块;玩家周围一段距离的区块总是加载,看起来和 view-distance
视距有关。
实际上,这些加载的距离由加载等级指定。
游戏中,区块的加载等级以 33 为界,分为四种区块位置类型(net.minecraft.world.server.ChunkHolder.LocationType
):
INACCESSIBLE
不会让区块加载入内存,但世界生成会进行BORDER
,仅有少量游戏逻辑会运行TICKING
,除了对生物进行 tick 以外,大部分逻辑都会运行ENTITY_TICKING
,意思是什么想必不需要多说游戏内与加载等级对应的是加载距离,算法是 distance = 33 - level
,表达的字面意思就是「加载周围的多少个方块」;由于地形生成的存在,实际的加载距离会大于指定的距离。
加载等级最大是 44,至于为什么是这个数字,我们一会儿再说。
可以见得,Ticket 的加载等级越低,就会加载更大范围的区块,而区块的加载等级越低,区块进行的计算就会越多,而读者你这时说不定已经猜出了区块的加载计算规则了。
当一个 Ticket 被提供给某个区块后,它会向周围的八个区块扩散,加载等级逐渐上升。这个过程被称为 Propagation。
假设我们往某一个区块提供了一个加载等级为 30 的 Ticket,那么这一片本身全部为 44 的区域,会变成这样:
33 33 33 33 33 33 33 33 32 32 32 32 32 33 33 32 31 31 31 32 33 33 32 31 30 31 32 33 33 32 31 31 31 32 33 33 32 32 32 32 32 33 33 33 33 33 33 33 33
而等级换算成对应的区块位置类型,游戏就会按照规则计算区块中的活动。
上面的内容足以描述游戏中的区块加载与卸载,但是对世界生成仍然不能清晰的描述:
INACCESSIBLE
等级的区块仍需更加细分。IWorld
接口的实现会有两个,分别是 World
和 WorldGenRegion
,也就是说,世界生成和游戏运行的世界有一定差距,以至于需要使用两个实现来描述。IChunk
的主要实现同样有两个。所以我们可以挖深一些。
在 Minecraft 中,区块的实现有两种,分别是 ChunkPrimer
和 Chunk
,或者可以称呼为 PROTOCHUNK
和 LEVELCHUNK
。
前者用于世界生成,而后者用于正常的游戏逻辑。
Minecraft 的世界生成是分阶段的,我们可以想象,游戏先生成了地形,然后放置树,接着…. 区块状态用于表示,在区块完成生成之前的状态。
游戏中共有 13 种区块状态:
状态 | 颜色 | 用途 |
---|---|---|
empty | #545454 | 表达一个空的区块 |
structure_starts | #999999 | 计算生成结构的位置 |
structure_references | #5F6191 | 保存上一步生成的结构位置 |
biomes | #80B252 | 生成生物群系并将它们保存 |
noise | #D1D1D1 | 生成世界的基础地形,包括之前的结构 |
surface | #726809 | 生成地形的表面,以及基岩 |
carvers | #6D665C | 「凿空」地形,也就是生成洞穴 |
liquid_carvers | #303572 | 如上,不过是用液体凿空 |
features | #21C600 | 进行地形生成的 decoration 阶段 |
light | #CCCCCC | 计算区块的光照 |
spawn | #F26060 | 为区块生成最初的一些生物 |
heightmaps | #EEEEEE | 看起来什么也没做 |
full | #FFFFFF | 区块加载完成,从 PROTOCHUNK 转换为 LEVELCHUNK |
这些区块状态对应了世界生成的过程,同时它们按照表中的顺序排列;同时,它们各自依赖前一个状态,也就是说,我们请求一个 full
的区块时,这个区块的状态会依次经过 empty
, structure_starts
…
以下是在 33 以上的加载等级对应的区块状态:
加载等级 | ChunkStatus |
---|---|
33- | full |
34 | features |
35 | liquid_carvers |
36-43 | structure_starts |
44+ | empty |
可以看到,Minecraft 并不是沿着加载等级依次排列区块状态的,因此在世界加载时,颜色的显示就并没有那么五彩斑斓了,不过如果愿意,我们仍然可以在这里观察到若干种颜色。
可以看到,右边多出来的那三个区块,从上到下分别处于 liquid_carvers
, surface
和 biomes
阶段。
Minecraft 中共有 8 种 net.minecraft.world.server.TicketType
,它们分别是
start
,用于出生点区块的加载,加载距离是 11 格,也就是 22 加载等级;由 43-22 得到 21,因此出生点的这个 Ticket 会在加载世界时一共加载 441 个区块dragon
,在与末影龙战斗时提供给区块,加载 9 格距离player
,自然就是玩家加载区块的方法,加载等级 31view-distance
(服务器)或渲染距离(客户端)有关forced
,用于 /forceload
指令和出生点区块的强制加载light
,加载等级 34,在区块状态为 light
时提供给区块,将区块加载以便计算光照portal
,在生成或寻找(这也就包含了生物使用传送门)对应的传送门后提供给区块,加载距离 3,存活 300 游戏刻post_teleport
,将生物传送到对应区块后,将区块保持加载一小段时间(5 gt),这可以让 /tp
指令传送生物后,使生物有机会更新到达的区块unknown
,用于游戏内任意代码调用了 getChunk
后加载区块,比如 getBlockState
,使区块加载 1 游戏刻以便获取区块信息需要注意的是,加载距离并不由 TicketType 指定,比如 post_teleport
在 /tp
指令使用时加载距离为 1,而其他情况下为 0
为世界的某个区块添加一个 Ticket 是再简单不过的事:
import net.minecraft.world.server.TicketType;this.world.getChunkProvider().registerTicket( TicketType.DRAGON, new ChunkPos(0, 0), 9, Unit.INSTANCE);
或许不限于原版的 TicketType,那么可以自行注册一个:
import net.minecraft.world.server.TicketType;public static final TicketType<Unit> CUSTOM = TicketType.create("some_ticket", (a, b) -> 0, 20 /* 可选的存活时间 */);
顺便一提,Unit
是 Mojang 的工具类,用来表示单元值,姑且可以理解为 void
。
还值得一提的是,加入游戏的 Ticket
是不持久化的,也就是说它们会随着游戏重启而消失。
Bukkit 平台提供了另外两种 TicketType
,看起来是这样的:
public static final TicketType<Unit> PLUGIN = create("plugin", (a, b) -> 0);public static final TicketType<org.bukkit.plugin.Plugin> PLUGIN_TICKET = create("plugin_ticket", (a, b) -> a.getClass().getName().compareTo(b.getClass().getName()));
前一种用于实现高版本的 chunk-gc.period-in-ticks
设置,具体细节就不过多说明了。
后一种则是提供给插件的,用于强制加载区块的方法,具体的 API 可以如下调用:
World world = Bukkit.getWorld("...");world.addPluginTicket(chunkX, chunkZ, plugin);
这会给对应区块一个加载等级 31 的 Ticket,以防止区块被卸载。
Mojang 实际上提供了一套显示世界区块的加载等级和位置类型的渲染器,不过不能通过游戏内的方式启用,可以手动调用 net.minecraft.client.renderer.debug.ChunkInfoDebugRenderer
来渲染。
由此图可以轻松找到加载等级 22 的区块为我们需要的加载区块。
由于玩家会加载区块,因此我们需要设置游戏规则 /gamerule spectatorsGenerateChunks false
,同时切换到旁观者模式,以阻止玩家自身的 Ticket。
由此可以看到一个 Ticket 的加载边界,位置等级逐渐降低。
其实本文到此本应结束了,但是在举办 TeaCon 2020 时,我们正好遇到了和这篇文章或许扯得上关系的一件事,因此一并在此记下。
事件的经过可以查看 sj 博客的这篇文章。
攻击刚刚开始时,我们发现服务器卡死,线程转储指向了 Chunk IO Worker,也就是说区块加载卡住了。同时世界文件夹中出现了大量数值特别大的 .mca
文件,因此可以判定有人在恶意利用漏洞加载区块。
但是 Forge 提供的加载区块事件是在 Chunk IO Worker 上调用的,因此异常栈信息并不能告诉我们是谁请求了这次区块加载。
经过研究发现,区块的加载等级更新逻辑位于 ChunkHolder#processUpdates
,并且是同步调用的,因此假如可以在这里设置一个断点,那么就可以找到哪些逻辑在加载区块了。
protected void processUpdates(ChunkManager chunkManagerIn) { ChunkStatus chunkstatus = getChunkStatusFromLevel(this.prevChunkLevel); ChunkStatus chunkstatus1 = getChunkStatusFromLevel(this.chunkLevel); boolean flag = this.prevChunkLevel <= ChunkManager.MAX_LOADED_LEVEL; boolean flag1 = this.chunkLevel <= ChunkManager.MAX_LOADED_LEVEL; ChunkHolder.LocationType chunkholder$locationtype = getLocationTypeFromLevel(this.prevChunkLevel); ChunkHolder.LocationType chunkholder$locationtype1 = getLocationTypeFromLevel(this.chunkLevel); if (flag) { // unload } boolean flag5 = chunkholder$locationtype.isAtLeast(ChunkHolder.LocationType.BORDER); boolean flag6 = chunkholder$locationtype1.isAtLeast(ChunkHolder.LocationType.BORDER); this.accessible |= flag6; if (!flag5 && flag6) { this.borderFuture = chunkManagerIn.func_222961_b(this); this.chain(this.borderFuture); } if (flag5 && !flag6) { // unload } boolean flag7 = chunkholder$locationtype.isAtLeast(ChunkHolder.LocationType.TICKING); boolean flag2 = chunkholder$locationtype1.isAtLeast(ChunkHolder.LocationType.TICKING); if (!flag7 && flag2) { this.tickingFuture = chunkManagerIn.func_219179_a(this); this.chain(this.tickingFuture); } if (flag7 && !flag2) { // unload } boolean flag3 = chunkholder$locationtype.isAtLeast(ChunkHolder.LocationType.ENTITY_TICKING); boolean flag4 = chunkholder$locationtype1.isAtLeast(ChunkHolder.LocationType.ENTITY_TICKING); if (!flag3 && flag4) { if (this.entityTickingFuture != UNLOADED_CHUNK_FUTURE) { throw (IllegalStateException)Util.pauseDevMode(new IllegalStateException()); } this.entityTickingFuture = chunkManagerIn.func_219188_b(this.pos); this.chain(this.entityTickingFuture); } if (flag3 && !flag4) { // unload } this.field_219327_v.func_219066_a(this.pos, this::func_219281_j, this.chunkLevel, this::func_219275_d); this.prevChunkLevel = this.chunkLevel;}
这个方法在区块的加载等级变化后,更新了 prevChunkLevel
的值,同时向区块安排任务。
前文讲到,在调用 getBlockState
一类的方法时,游戏会为没有加载的区块提供一个 unknown
的 Ticket,因此这类方法调用如果加载了区块,就应该会反应在这个方法。
接下来就简单了,在这里打一个断点,输出一下堆栈信息,轻松的就发现了 TheOneProbe 的漏洞。
区块加载系统在 1.14 迎来了较大的变化,Mojang 以一种更加合理的方式来管理区块,大部分老版本用于卸载不需要的区块的服务端插件也逐渐退出了历史舞台。
如果你对世界生成中的区块生成有兴趣,可以查看 Yaossg 的这篇文章,尽管是 1.13 的,但是仍然有价值一读。
同时 Yaossg 对世界生成的过程也做了较为详细的解释,具体可以查看这个仓库。
官方 Wiki 中的区块一节也对这个系统进行了一定的讲解,尽管 Wiki 会更注重游戏细节而非技术细节。
感谢最初介绍这个系统的 Drovolon,以及他的文章。官方 Wiki 大部分内容来源于此。
]]>我们都知道:
2 + 2 == 5
有力的佐证如下:
public class Main { public static void main(String[] args) throws Exception { Class<?> cache = Integer.class.getDeclaredClasses()[0]; Field c = cache.getDeclaredField("cache"); c.setAccessible(true); Integer[] array = (Integer[]) c.get(cache); array[132] = array[133]; System.out.printf("%d", 2 + 2); }}
输出为 5
,结果与事实相符,2 + 2 可以等于 5,也可以根据需要等于任何数字。
但是这是为什么呢?
我们都知道,Java 中数据分为原生类型(Primitive Types)和引用类型,比如 int
就是原生类型,Integer
就是引用类型。
原生类型并不能作为泛型参数,因此以下的代码是不可以通过编译的:
List<int> list = new ArrayList<>();
这是因为,泛型在运行时会 擦除
,Java 的泛型是假的泛型,不能在运行时获得泛型的类型,除非保存 TypeToken
之类的东西。
我们如果想往 List
里放入 int
,则可以使用其引用类型:
List<Integer> list = new ArrayList<>();list.add(1);list.add(2);int i = list.get(0);
但是这样就有了一个问题,就是在 list.add(1)
中,根据泛型参数我们的 add
方法接受的是 Integer
类型,而传入的 1
实际上是 int
类型,这样类型不就冲突了吗?
因此,Java 在 1.5 的版本与泛型同时引入了自动装箱拆箱(Auto boxing/unboxing)机制,自动完成原生类型与引用类型之间的互相转换。
当需要一个引用类型而实际传入了原生类型,Java 就会为我们自动装箱,把原生类型变成引用类型,如上文的 list.add(1)
,编译完成后实际上是:
list.add(Integer.valueOf(1));
而需要原生类型而实际为引用类型时,Java 就会尝试自动拆箱,比如 int i = list.get(0)
,最后会变成:
int i = list.get(0).intValue();
而观察 Integer 类的源码,可以看出来大概长这样:
public class Integer extends Number implements Comparable<Integer> { private final int value; public Integer(int value) { this.value = value; } public int intValue() { return value; } public static Integer valueOf(int i) { if (i >= IntegerCache.low && i <= IntegerCache.high) return IntegerCache.cache[i + (-IntegerCache.low)]; return new Integer(i); }}
然后你注意到了什么。
你可能会想(不会想也得必须给我想):
如果许多数字都装箱,那不会白白消耗许多内存吗?
因此,Java 为 Integer 类加入了缓存,对于小整数会直接引用提前创建好的实例作为 valueOf
的返回值。而提前创建好的这些实例,被放在 Integer 类里的 IntegerCache 类中。
Java 默认的缓存范围是 -128 到 127,因此 4 就在 132 的位置。
如此一来,把这里本应表示 4
的数字换成 5
的实例,就可以证明 2 + 2
实际上等于 5
了,并且这个数字可以根据需要任意改变。
int i = 2 + 2; // 4Integer boxed = Integer.valueOf(i); // 返回 IntegerCache[132],被修改为了 5System.out.printf("%d", boxed); // 5
顺便,缓存的范围可以自行增加,通过一个 -XX:AutoBoxCacheMax
参数指定上限,不过必须大于 127
。
当然,不要把它带进生产环境。
]]>对话并不罕见,在 QuickShop 中,插件会向玩家询问“你要买几个东西”,玩家则在聊天栏中输入对应的值;在 PlotSquared 中,玩家需要不断地输入对应的命令来配置地皮生成的参数,而输入命令也是另一种形式的对话。
对话的实现,Bukkit 为开发者准备了一套 Conversations API,编程开发板块的这篇帖子有简单的介绍。
本篇将会介绍另一种实现它的方法,简单的可以概括为,在一个方法里流畅的处理对话,比如这样:
import org.bukkit.entity.Player;public class SomeClass { public void ask(Player player) { player.sendMessage("吾与徐公孰美?"); String answer = getAnswer(player); assert answer.equals("徐公不若君之美也!"); }}
接下来的篇幅,就将讨论上文的 getAnswer
如何实现。
实现对话,无非是这种逻辑:
按照正常的方法编写,我们需要一个监听器监听 PlayerChatEvent
或者它的异步版本,需要记录玩家的状态(玩家是不是在一个对话中 / 对话进行到了哪里),如果是诸葛亮王朗量级的超长对话,那么判断状态 / 更新状态的代码量将不堪设想。你还需要考虑玩家掉线/玩家不回答的情况,这就会又引入别的监听器和定时器。
因此,我们会想用上文代码一样的方式处理,无需记录状态,但是显然,getAnswer
不可能在调用的时候就有结果,玩家这时可能还不知道他被提了一个美不美的问题,而答案有可能会在未来提供,因此我们有了 Future
接口。
这是 Future 接口的定义:
package java.util.concurrent;public interface Future<V> { boolean cancel(boolean mayInterruptIfRunning); boolean isCancelled(); boolean isDone(); V get() throws InterruptedException, ExecutionException; V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;}
注意到上面加粗的有可能了吗?为什么会 有可能 呢?
cancel
)get
方法超时了)get
方法抛出了异常)get
方法被中断了)可以得知,你能准确的从玩家嘴里问出有效的答案实属不易,而 Future
接口就可以表示一个可能出现异常的、会在未来得到结果的东西。Future
的泛型 V
,则表示得到的值。
因此,上面的代码可以改成这样:
public class SomeClass { public Future<String> getAnswer(Player player) { return // ??? } public void ask(Player player) { try { player.sendMessage("吾与徐公孰美?"); String answer = getAnswer(player).get(); assert answer.equals("徐公不若君之美也!"); } catch (Exception e) { e.printStackTrace(); } }}
但是,Future
从哪里来呢?本篇的答案是 CompletableFuture
。
望文生义,CompletableFuture
代表着可以完成的 Future
,这与本篇的目的不谋而合(不然呢):玩家输入消息后,getAnswer
方法返回的 Future
就该完成了。
我们来了解一下 CompletableFuture
中比较重要的几个方法:
package java.util.concurrent;public class CompletableFuture<T> implements Future<T>, CompletionStage<T> { public CompletableFuture() { } public T join(); public boolean complete(T value); public boolean completeExceptionally(Throwable ex);}
CompletableFuture
实现了 Future 接口,自然有 Future
接口的所有方法Future
join
方法与 get
的效果类似,但有些许不同,在使用者看来最显著的区别就是,join
并不让你强制处理异常,虽然异常永远都在complete
和 completeExceptionally
分别代表正常完成和异常完成因此,我们不难将上面的代码改成这样:
public class SomeClass { public Future<String> getAnswer(Player player) { CompletableFuture<String> future = new CompletableFuture(); // 在别的地方调用 future.complete() return future; } public void ask(Player player) { try { player.sendMessage("吾与徐公孰美?"); String answer = getAnswer(player).get(); assert answer.equals("徐公不若君之美也!"); } catch (Exception e) { e.printStackTrace(); } }}
于是,我们也就不难写出以下的代码:
public class SomeClass { public void ask(Player player); // ... public Future<String> getAnswer(Player player) { CompletableFuture<String> future = new CompletableFuture<>(); AskLifeExperience listener = new AskLifeExperience(player.getUniqueId(), future); Bukkit.getPluginManager().registerEvents(listener, plugin); return future; } public class AskLifeExperience implements Listener { private final UUID uuid; private final CompletableFuture<String> future; public AskLifeExperience(UUID uuid, CompletableFuture<String> future) { this.uuid = uuid; this.future = future; } @EventHandler public void on(AsyncPlayerChatEvent event) { if (event.getPlayer().getUniqueId().equals(uuid)) { future.complete(event.getMessage()); HandlerList.unregisterAll(this); } } }}
逻辑清晰明了,注册一个监听器,在玩家聊天的时候完成 Future
。
转眼一想,既然 getAnswer
需要一定时间才会取得答案,那 ask
方法不就会消耗很多时间了吗?因此,我们要异步调用 ask
。
当不在主线程进行操作的时候,我们都应该想一想,这样安全吗?
从上到下看一遍,不难问出这些问题:
sendMessage
安全吗?CompletableFuture#complete
安全吗?(不然呢)Future#get
方法一定会返回吗?根据一篇写的很不错的文档(这篇文档对水桶的 scheduler 有较为详细的介绍),这几个东西是线程安全的:
sendMessage
(发包)scheduler
包PluginManager#callEvent(event)
因此应该将注册事件部分的代码通过 Scheduler 转移到主线程完成。
最终的完整(但不完善)的方法如下,监听器与上文相同:
public class SomeClass { private Plugin plugin = null; public void ask(Player player) { Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> { try { player.sendMessage("吾与徐公孰美?"); String answer = getAnswer(player).get(15, TimeUnit.SECONDS); assert answer.equals("徐公不若君之美也!"); } catch (Exception e) { e.printStackTrace(); } }); } public Future<String> getAnswer(Player player) { CompletableFuture<String> future = new CompletableFuture<>(); Bukkit.getScheduler().runTask(plugin, () -> { AskLifeExperience listener = new AskLifeExperience(player.getUniqueId(), future); Bukkit.getPluginManager().registerEvents(listener, plugin); }); return future; }}
当然,代码写完,还应该问自己几个问题:
Player
实例不再可用,怎么办呢?这些问题不是本篇重点,就不说了。
可以看出,优美的写一串对话,所需代码量其实并不多,寥寥数十行就可以了。
线程安全十分重要。
CompletableFuture
还有许多实用的方法,可以用于各种耗时的操作,如 获取数据库的信息后,将其应用于服务器中
。希望读者能够自行多加了解。
zzzz 编写了一篇协程教程,可以写出与本篇主方法非常类似的代码,虽然背后的原理大不相同,比如它全部在主线程上运行。
tdiant 编写了一篇十分全面的水桶教程,对 Scheduler 和其他部分都有很多讲解。
]]>从 1.13 起,Forge 抛弃了原来的 LauncherWrapper,改用了 cpw 编写的 ModLauncher,也就是说,Mixin 原来基于 Tweaker 的那一套不能用了,为此,Mixin 引入了 MixinConnector
。
首先,你需要让一个类实现 IMixinConnector
,比如:
import org.spongepowered.asm.mixin.connect.IMixinConnector;public class ExampleConnector implements IMixinConnector { @Override public void connect() { }}
然后在其中添加需要添加的 Mixin 配置文件:
import org.spongepowered.asm.mixin.Mixins;import org.spongepowered.asm.mixin.connect.IMixinConnector;public class ExampleConnector implements IMixinConnector { @Override public void connect() { Mixins.addConfiguration("mixins.example.json"); }}
最后,你需要在 MANIFEST.MF 文件中,指定这个 jar 文件使用的 Mixin Connector:
文件 META-INF/MANIFEST.MF
Manifest-Version: 1.0MixinConnector: ExampleConnector
记得在最后空一行。
当然,也可以使用构建工具,比如 Gradle:
jar { manifest.attributes( 'MixinConnector': 'ExampleConnector' )}
剩余的内容就是普通的 Mixin 编写了,包括编写 Mixin 类,配置文件。
当然,截至本文发出时,ModLauncher 生态仍然没有官方的 Mixin 支持,因此在 ModLauncher 引导 Mixin 需要一点奇淫巧计。
比如我的项目 https://github.com/IzzelAliz/MixinLoader ,下载之后扔到 mods 文件夹,你的 Forge 就有了 Mixin 环境。
Mixin 0.8 带来了新的 tsrg
混淆表格式支持,以及 MixinGradle 对 ForgeGradle 3+ 的支持。升级至 MixinGradle 0.7 就可以快乐生成 refmap
了。
同时,现在 Mixin Config 可以继承自其他的,只需要
文件 mixins.example.json
{ "parent": "mixins.parent.json"}
更多特性可以阅读官方的 Release note
]]>简单的看了一下,一共添加了 4 个接口,其中 PersistentDataHolder
接口标记了对应的实现可以存储数据。
实现该接口的主要有三类比较重要:
TileState
,就是说可以往部分方块里存数据;ItemMeta
,也就是我们可以正大光明的往物品里存数据了。接口 PersistentDataHolder
:getPersistentDataContainer
用于获取持久化数据的存储容器,所有数据的读写都在这里
接口 PersistentDataContainer
:
有 get
has
set
等一系列方法,类似于一个 Map,键为 NamespacedKey
,用于区分不用插件的不同数据。
PersistentDataType<T, Z>
:表示存储的数据类型,其中接口内部的字段定义了常用的一些数据类型,比如 Integer
, String
等等。
可以通过实现该接口实现自定义数据的序列化和反序列化。
接口的两个泛型参数中,
T
表示存储的原生类型,目测必须为那几个内置的字段的类型之一Z
表示你的自定义类型拿我上一篇文做例子
static class PlayerData { int hp = 20; double strength = 0; boolean haveJob; Job job;}static class Job { String name; int level; int exp; String prefix;}
仍然是这两个类,这次我们不用保存在别的文件里了。
利用新的 Bukkit API,可以直接存到 Player 里去,因为 Player
继承了 PersistentDataHolder
。
首先要把我们的类转换成原生数据类型,按照上一篇,我们用 byte[]
存数据。
首先,实现一个 PersistentDataType<byte[], Job>
,按照上一篇教程,大概长这样:
(不重复展示怎么读写字符串的方法)
static class JobDataType implements PersistentDataType<byte[], Job> { static final JobDataType INSTANCE = new JobDataType(); @Override public Class<byte[]> getPrimitiveType() { return byte[].class; } @Override public Class<Job> getComplexType() { return Job.class; } @Override public byte[] toPrimitive(Job complex, PersistentDataAdapterContext context) { ByteBuf buffer = Unpooled.buffer(); writeString(buffer, complex.name); buffer.writeInt(complex.level); buffer.writeInt(complex.exp); writeString(buffer, complex.prefix); return buffer.array(); } @Override public Job fromPrimitive(byte[] primitive, PersistentDataAdapterContext context) { Job job = new Job(); ByteBuf buffer = Unpooled.wrappedBuffer(primitive); job.name = readString(buffer); job.level = buffer.readInt(); job.exp = buffer.readInt(); job.prefix = readString(buffer); return job; }}
接着,把数据存到 Player 里:
Plugin plugin = /*...*/;Player player = /*...*/;PersistentDataContainer container = player.getPersistentDataContainer();// 存Job job = /*...*/;container.set(new NamespacedKey(plugin, "playerJob"), JobDataType.INSTANCE, job);// 取Job get = container.get(new NamespacedKey(plugin, "playerJob"), JobDataType.INSTANCE);
十分的简单。
把 Player
换成 ItemMeta
,就是向物品中存储数据。
注意到 PersistentDataType
里有一个名为 CONTAINER
的字段,可以合理猜测内部原生类型支持 PersistentDataContainer
,因此不难写出这样的代码:
static class JobContainerType implements PersistentDataType<PersistentDataContainer, Job> { static JobContainerType INSTANCE = new JobContainerType(); @Override public Class<PersistentDataContainer> getPrimitiveType() { return PersistentDataContainer.class; } @Override public Class<Job> getComplexType() { return Job.class; } @Override public PersistentDataContainer toPrimitive(Job complex, PersistentDataAdapterContext context) { PersistentDataContainer container = context.newPersistentDataContainer(); container.set(new NamespacedKey(plugin, "name"), PersistentDataType.STRING, complex.name); container.set(new NamespacedKey(plugin, "level"), PersistentDataType.INTEGER, complex.level); container.set(new NamespacedKey(plugin, "exp"), PersistentDataType.INTEGER, complex.exp); container.set(new NamespacedKey(plugin, "prefix"), PersistentDataType.STRING, complex.prefix); return container; } @Override public Job fromPrimitive(PersistentDataContainer primitive, PersistentDataAdapterContext context) { Job job = new Job(); job.name = primitive.get(new NamespacedKey(plugin, "name"), PersistentDataType.STRING); job.level = primitive.get(new NamespacedKey(plugin, "level"), PersistentDataType.INTEGER); job.exp = primitive.get(new NamespacedKey(plugin, "exp"), PersistentDataType.INTEGER); job.prefix = primitive.get(new NamespacedKey(plugin, "primitive"), PersistentDataType.STRING); return job; }}
看起来也还行。
]]>总之香的不行,上一篇的 Groovy 代码高亮混乱的问题也解决了。
测试一下,
@Overridepublic <C extends CastingSkill<E>, E extends EntitySkill<?, C>> Optional<C> operate(Class<E> cl, SkillOperation<? super C> operation) throws UnsupportedOperationException { Optional<Entity> entity = getEntity(); if (entity.isPresent()) { if (multimap.containsKey(cl)) { Collection<C> castingSkills = getCastingSkills(cl); if (castingSkills.size() > 0) { C skill = castingSkills.iterator().next(); return Optional.of(operate(skill, operation)); } } ProfessionSubject subject = ProfessionService.instance().getOrCreate(entity.get()); SkillTree skillTree = subject.getMerged(); Optional<E> optional = skillTree.find(cl); if (optional.isPresent()) { E skill = optional.get(); C cast = skill.createCast(this); return Optional.of(operate(cast, operation)); } } return Optional.empty();}
没 IDEA 里面好看。
]]>本文的代码是 Groovy,你可以看做是没有行末分号、异常不用捕获的 Java。
本文代码以 MIT License 开源。
那么开始。
配置文件是最易用也最常用的方法之一,
但是这不是本文的重点,因为配置文件的使用教程实在是太多了。
尽管配置文件的本意是给使用者自定义你的插件/Mod的行为,但是用来存储数据也是可以的。
为方便读者,这里给出一些其他人的教程。
数据库是个挺好的话题,给出一个 MySQL 教程,
这篇文章不会讲解数据库,因为数据库也不是本文重点。但是额外提醒一点,用数据库注意阻塞和线程安全。
存配置文件看起来很不优雅,还占空间;而存数据库对于一般插件好像又有点多此一举了,那么有没有又快又省空间的方法呢?
这节的标题就是,这一节也是真正的重点。
例子,我们要做一个玩家属性插件:
class PlayerData { int hp = 20 double strength = 0 boolean haveJob Job job}class Job { String name int level int exp String prefix // 假设我们的插件甚至还要搞聊天前缀}
然后把这个实例变成二进制数据。
模仿 Bukkit 的那个 Serializable,利用 Netty Buffer 库,我们可以搞这么一个二进制数据序列化/反序列化的系统出来:
import com.google.common.collect.Mapsimport io.netty.buffer.ByteBufimport io.netty.buffer.Unpooledimport org.bukkit.plugin.java.JavaPluginimport java.nio.charset.StandardCharsetsimport java.util.function.Functioninterface Serializable { void serialize(ByteBuf buf)}class PlayerData implements Serializable { int hp = 20 double strength = 0 boolean haveJob = false Job job @Override void serialize(ByteBuf buf) { buf.writeInt(hp) buf.writeDouble(strength) buf.writeBoolean(haveJob) if (haveJob) { job.serialize(buf) } } static PlayerData deserialize(ByteBuf buf) { PlayerData data = new PlayerData() data.hp = buf.readInt() data.strength = buf.readDouble() data.haveJob = buf.readBoolean() if (data.haveJob) { data.job = Job.deserialize(buf) } return data }}class Job implements Serializable { String name int level int exp String prefix // 假设我们的插件甚至还要搞聊天前缀 @Override void serialize(ByteBuf buf) { Util.writeString(buf, name) buf.writeInt(level) buf.writeInt(exp) Util.writeString(buf, prefix) } static Job deserialize(ByteBuf buf) { Job job = new Job() job.name = Util.readString(buf) job.level = buf.readInt() job.exp = buf.readInt() job.prefix = Util.readString(buf) return job }}class Util { static Map<Class<?>, Function<ByteBuf, ?>> deserializers = Maps.newHashMap() static <T> void registerDeserializer(Class<T> cl, Function<ByteBuf, T> function) { deserializers.put(cl ,function) } static <T> T deserialize(Class<T> cl) { Function<ByteBuf, ?> function = deserializers.get(cl) if (function != null) { return (T) function.apply(Unpooled.buffer()) } throw new Exception("Not registered class $cl") } static byte[] serialize(Object obj) { if (obj instanceof Serializable) { ByteBuf buf = Unpooled.buffer() obj.serialize(buf) return buf.array() } return null } static void writeString(ByteBuf buf, String value) { if (value == null) { buf.writeInt(0) return } byte[] arr = value.getBytes(StandardCharsets.UTF_8) buf.writeInt(arr.length) buf.writeBytes(arr) } static String readString(ByteBuf buf) { int len = buf.readInt() if (len == 0) return null byte[] arr = new byte[len] buf.readBytes(arr) return new String(arr, StandardCharsets.UTF_8) }}class Plugin extends JavaPlugin { @Override void onEnable() { Util.registerDeserializer(PlayerData, PlayerData::deserialize) Util.registerDeserializer(Job, Job::deserialize) }}
]]>以下术语会用于这篇教程中:
Σ
:字母表集合,在这篇教程中为能打在这个帖子中的任何字符,除了 ε
,因为要用它表示空串
字符串
:来自 Σ
的0或N个字符的有限序列,也称串
在这篇教程中,字符串会用
这种格式
包围
空串
:含有0个字符的字符串,也会用 ε
表示
子串
:字符串掐头去尾(0或N个字符)得到的新字符串
abcbabc
的子串有abcbabc
abcb
babc
ba
ε
也就是说在 Java 里,某串A的子串B 可以让A.contains(B) = true
子序列
:字符串随机取出字符(0或N个)得到的新字符串
abcbabc
的子序列有abcbabc
abab
ca
ε
真子串
:除了自身以外的子串
真子序列
:除了自身以外的子序列
同上,而且这些术语会用的很多:
字符串的连接
:两个串的连接为第一个串后紧跟着第二个串的串
假设 A 是字符串
hailuo
,B 是字符串is
,C 是字符串handsome
,那么 A B C 的连接表示为ABC
,也就是hailuo is handsome
字符串的或
:两个串的或为 为第一个串或第二个串的串
假设同上,A C 的或表示为
A|C
,也就是hailuo
字符串 或者handsome
字符串
字符串的Kleene闭包
:为0或N个某个串的连接
假设 A 为字符串
ab
,A*
为一个启发式寻路算法为 εab
abab
ababababab
….
(字符串)
:和 字符串
是一样的
在描述一个字符串时,你可能已经遇到了这类似的问题:
aac|dbb
是指 {aac
,dbb
} 还是 {aacbb
, aadbb
} 呢abcd*
是指 {ε, abcd
, abcdabcd
, ….} 还是 {abc
, abcd
, abcdddd
, …} 呢为了解决这些问题,我们可以加括号:
(aac)|(dbb)
指 {aac
,dbb
},而 aa(c|d)bb
指 {aacbb
, aadbb
}(abcd)*
指 {ε, abcd
, abcdabcd
, ….},而 abc((d)*)
指 {abc
, abcd
, abcdddd
, …}但是,大量使用括号显然不美观,所以定义以下操作的优先级:
(a) > a* > ab > a|b
也就是说,最上面的两个问题的答案分别为 {aac
,dbb
} 和 {abc
, abcd
, abcdddd
, …}
理解以上内容后,你就有足够的基础理解正则表达式了。
为了防止你的理解不够深刻,你可以思考一下以下几个问题:
abcd
的子串、真子串、子序列、真子序列(a|b)*
描述的是什么串a((a|(b*))((b)b|(cc)*)|dd)
可以去掉哪些括号并不影响其表达的字符串答案见文末
正则表达式,可用于匹配给出的字符串是否满足要求,也可以用于从字符串中提取需要的信息。
初代的正则表达式只有4种语法:
ab
a|b
a*
(a)
等于 a
和上一章介绍的优先级是相同的。
本章从一个例子开始:
先来看看定义,我们可以这么表示一个小数:
$123.456×2^{789}$
$整数.零数×2^{指数}$
Java 的浮点数可以表示为以下格式:
0000.00000E0000
包含一个整数,一个可选的 . 以及一个零数,一个可选的 E 以及指数
我们将会尝试用正则表达式匹配这个浮点数字符串。
由于正则表达式一眼望上去不是很直观(做了上一章的第三道题的应该能理解为何),我们先用一种其他的方式表达正则表达式:
用以下的方式表达一个正则表达式:
n1 -> r1n2 -> r2n3 -> r3....
其中 ni 表示一个名字,为了和正则区分开来名字使用斜体。每个 ri 都指一个正则表达式,并可以引用 j < i 的 nj 来代替之前定义的正则。
如前所述,浮点数包含整数:
digit -> 0|1|2|3|4|5|6|7|8|9sign -> + | - | εnumber -> digit digit *
以及一个可选的零数:
optional_fraction -> .number | ε
以及一个可选的指数:
optional_exponent -> ( e | E ) sign number | ε
浮点数就可以表示为:
floatingPointNumber -> sign number optional_fraction optional_exponent
而将名称替换回正则表达式后,最终结果为:
(+|-|)((0|1|2|3|4|5|6|7|8|9)(0|1|2|3|4|5|6|7|8|9)*)(.((0|1|2|3|4|5|6|7|8|9)(0|1|2|3|4|5|6|7|8|9)*)|)((e|E)(+|-|)((0|1|2|3|4|5|6|7|8|9)(0|1|2|3|4|5|6|7|8|9)*)|)
这个正则表达式只使用了上面的四种语法,这也是你正式写出的第一个正则表达式(如果你之前没写过的话),尽管它又长又丑,但是它可以匹配各种能在 Java 中编译通过?的浮点数。
考虑
digit -> 0|1|2|3|4|5|6|7|8|9
太麻烦了,我们添加一种语法:[abc]
表示 a|b|c
即提供的字符中的任意一个,并且还提供了语法 [a-b]
表示从 a 到 b 的连续的字符,如 [a-z]
表示所有小写字母,[a-zA-Z]
表示所有字母。
因此上面的定义可以更换为
digit -> [0-9]
考虑
number -> digit digit *
太麻烦了,我们添加一种语法:a+
表示 1或N个a,因此上面的定义可以更换为
number -> digit +
考虑
sign -> + | - | εoptional_fraction -> .number | εoptional_exponent -> ( e | E ) sign number | ε
它们都使用了 xxxx | ε
表达可选的字符串,太麻烦了,我们添加一种语法 a?
表达0或1个a,因此上面的定义可以更换为:
sign -> [+-]?optional_fraction -> ( .number )?optional_exponent -> ( [eE] sign number )?
因此,浮点数的正则表达式可以化简为:
[+-]?[0-9]+(.[0-9]+)?([Ee][+-]?[0-9]+)?
这是你写的第二个正则表达式,它短了许多,功能与上面第一个一模一样。但是,这还不是最短的。
你可能注意到了上面出现了3次 [0-9]
,并且你以后可能还会用无数次 [0-9]
来匹配数字,为了防止这种情况发生,元字符出现了。
元字符比较多,但是有几个最好记住(标红),鉴于网上一搜就能知道元字符有哪些,但是这里还是会提供一份。
同时,既然有了元字符以及上面新加的新语法,类似 . + - \ ^ $ 的符号都有了特殊的意义,在作为字符使用时应在前面加上 \
代码 | 说明 |
---|---|
. | 匹配除换行符以外的任意字符 |
\w | 匹配字母或数字或下划线或汉字 |
\s | 匹配任意的空白符 |
\d | 匹配数字 |
\b | 匹配单词的开始或结束 |
^ | 匹配字符串的开始 |
$ | 匹配字符串的结束 |
\w | 匹配任意不是字母,数字,下划线,汉字的字符 |
\S | 匹配任意不是空白符的字符 |
\D | 匹配任意非数字的字符 |
\B | 匹配不是单词开头或结束的位置 |
\a | 报警字符(打印它的效果是电脑嘀一声) |
\b | 通常是单词分界位置,但如果在字符类里使用代表退格 |
\t | 制表符,Tab |
\r | 回车 |
\v | 竖向制表符 |
\f | 换页符 |
\n | 换行符 |
\e | Escape |
\0nn | ASCII代码中八进制代码为nn的字符 |
\xnn | ASCII代码中十六进制代码为nn的字符 |
\unnnn | Unicode代码中十六进制代码为nnnn的字符 |
\cN | ASCII控制字符。比如\cC代表Ctrl+C |
\A | 字符串开头(类似^,但不受处理多行选项的影响) |
\Z | 字符串结尾或行尾(不受处理多行选项的影响) |
\z | 字符串结尾(类似$,但不受处理多行选项的影响) |
\G | 当前搜索的开头 |
\p{name} | Unicode中命名为name的字符类,例如\p{IsGreek} |
也就是说,浮点数的正则可以写作:(注意小数点及正负号的转义)
[\+\-]?\d+(\.\d+)?([Ee][\+\-]?\d+)?
a{m,n}
表示重复m到n遍的a
a{n}
表示重复n遍的a
[abc]
的语法(叫字符类),还有更多用途:
[^a-f]
匹配除了abcdef
的任意字符
[a-d[m-p]]
取a-z
和m-p
的并集,即[a-dm-p]
[a-z&&[def]]
取a-z
和def
的交集,即[def]
[a-z&&[^bc]]
除了bc
以外的a-z
,即[ad-z]
(补集)
[a-z&&[^m-p]]
除了m-p
以外的a-z
,即[a-lq-z]
(补集)
到此,你已经掌握了正则表达式中的大部分的内容了,如果不需要更高级的用途,你可以在此停止了。
当然,在此提供一些题目供思考练习
答案见文末
在之前,我们介绍了括号操作符,用于改变优先级。括号不止这点用处。
括号可用于分组,我们再次拿出这个匹配浮点数的正则表达式作为例子:
[\+\-]?\d+(\.\d+)?([Ee][\+\-]?\d+)?
在此例中,共有2个括号对,我们称之为两个组:(\.\d+)
和 ([Ee][\+\-]?\d+)
,分别为组 1 组 2;同时,整个正则表达式作为默认的组 0。
当然,按照组数一个一个数实在是太麻烦了,好在我们可以给组命名:
(?<name>regex)
为一个名为 name 的组。(?:regex)
为一个无名且不进行计数的组。
组有何用呢?接下来你就能看到了。
后向引用可用于匹配之前出现过的文本,使用组作为标记。
其中,我们可以使用 \1 \2 \n
代表数字组匹配的字符串,也可以使用 \k<name>
匹配之前 name 组匹配的字符串。
举个例子,假如我们只想匹配整数和零数相同的小数,我们可以写:
(\d+)\.\1
其中后面的 \1 为前一个组 (\d+)
匹配的数字,所以这个正则表达式可以匹配 123.123,却不匹配 123.124。
当然,既然可以给组命名,那么也就可以这么写:
(?<number>\d+)\.\k<number>
这个正则表达式作用和上面相同。
零宽度匹配,也有人叫它零宽断言。
零宽度是指,这个匹配组并不会消耗字符:
假如说你想匹配1.某个前方或后方满足特殊要求的字符串,但是2.前方或者后方的字符可能还需要用于其他的匹配,
普通的匹配会吃掉这些字符用于1.满足要求的字符,而导致用于2.还需要匹配的部分匹配失败。
也就是说,零宽匹配中的正则表达式仅用于要求测试,不影响其他匹配。读不懂这段话没关系,可以结合后例。
零宽肯定先行断言:
reg1(?=reg2)
断言 reg1 匹配的字符串后方出现匹配 reg2 的字符串
零宽否定先行断言:reg1(?!reg2)
断言 reg1 匹配的字符串后方不出现匹配 reg2 的字符串
零宽肯定后行断言:(?<=reg2)reg1
断言 reg1 匹配的字符串前方出现匹配 reg2 的字符串
零宽否定后行断言:(?<!reg2)reg1
断言reg1 匹配的字符串前方不出现匹配 reg2 的字符串
这里的先行后行是指,在匹配的回溯过程中,当找到 reg1 的内容后,如果向文本前方(正向)查找断言,则为先行(lookahead);
若找到 reg1 后,需要向文本后方(倒着)查找断言,则为后行(lookbehind)。方便记忆的方法就是,先行 reg1 放前,后行 reg1 放后。
接着举几个例子:
aaa(?=bbb)bbb
,可以匹配aaabbb
,此例说明何为零宽:不占用后续匹配的字符串abc(?=def)
,不能匹配任何东西,因为整个正则表达式需要满足仅含有 abc 三个字符(断言是不会消耗字符的),但是断言又要求 abc 后跟随着 defabc(?=def).*def(?=ghi).*
,匹配文段中跟随者def的abc,并且在不远的后面出现了跟随着ghi的def,所以这个例子可以匹配abcdefxxxxxxdefghi
,也可以匹配abcdefghi
。abc(?=def).*def(?=ghi)
,无法匹配abcdefghi
,原因参见第二个例子。
在之前我们讲到重复时,如果你自己做过测试,那么你会发现,a.*b
会匹配 ababab
中的 ababab
而不是 ab
;
也就是说,默认的重复语 *
尝试匹配最长的那个字符串。假如我们想匹配更短一些的呢?
贪婪量词:
regex
,表示能匹配 regex 的最长字符串,比如a*
匹配aaaaa
会匹配aaaaa
懒惰量词:regex?
,表示能匹配 regex 的最短字符串,比如a*?
匹配aaaaa
会匹配ε
占有量词:regex+
,表示能不回溯地匹配 regex 的最长字符串,比如a*+
匹配aaaaa
会匹配aaaaa
这样简略的介绍可能没人能理解什么是占有量词,并且对其他两种没有一个直观的认识,那么来看例子:
字符串模板为 abbbabbcbbabbc
贪婪的正则表达式为
[abc]*c
,会匹配abbbabbcbbabbc
(尽可能匹配长)
懒惰的正则表达式为[abc]*?c
,会匹配abbbabbc
和bbabbc
(尽可能匹配短)
占有的正则表达式为[abc]*+c
,什么也不会匹配。
为什么呢?
占有模式下,[abc]*+
这一部分,可以完全匹配整个字符串,而占有模式下不进行回溯,也就是说 [abc]*+
会用掉所有字符,而使最后一个 c 没有任何字符匹配,因此匹配失败。
而贪婪模式下,尽管 [abc]*
可以匹配整个字符串(abbbabbcbbabbc),但是因为还有一个 c 没有匹配,因此回溯向前查找,最终[abc]*
匹配的是 abbbabbcbbabb 。
(?>regex)
,表示一旦这个组匹配成功后,不再对这个组进行回溯。
例子:
a(bc|b)c
可以匹配 abcc 和 abc,因为在处理第一个或操作bc|b
,正则引擎记住了要在这里回溯,因此会对bcc和bc都进行匹配。a(?>bc|b)c
可以匹配 abcc,但不会匹配 abc,因为第一遍匹配 abc 时,a(?>bc|b)
这一部分已经匹配了 abc,因此不在这里回溯,而最后的一个 c 当然就匹配失败了。
\Q
表示非转义字符串的开始,\E
表示非转义字符串的结束。
比如 \Q[\+\-]?\d+(\.\d+)?([Ee][\+\-]?\d+)?\E
会匹配 [\+\-]?\d+(\.\d+)?([Ee][\+\-]?\d+)?
这个字符串。
本章有一定难度,请对提供的所有例子都进行思考,了解”为什么会这样匹配”。
本章到此结束。
按照惯例,提供一些题目进行思考练习。
\b(?<first>\w+)\b\s+(\b\k<first>\b\s*)+
是干什么用的^((?!RegularExpression).)*$
是干什么用的答案见文末
比如把 markdown 的图片链接转换为 discuz 的
点击 replace 后变成了
如图,可以使用组
package io.izzel.strtutor;import java.util.regex.Matcher;import java.util.regex.Pattern;public class Main { public static void main(String[] args) { // 正则表达式 Pattern pattern = Pattern.compile("\\b(?<first>\\w+)\\b\\s+(\\b\\k<first>\\b\\s*)+"); // 输入的内容 Matcher input = pattern.matcher("A b c c b a b f f d."); while (input.find()) { // 默认第 0 组,还记得前面讲的怎么分组吗 System.out.println(input.group()); // 叫 first 的组 System.out.println(input.group("first")); // 第二个组 System.out.println(input.group(2)); } }}
更多 Java 相关的 API 比如 String#replaceAll
String#split
String#matches
以及 Pattern
Matcher
的高级用法请结合搜索引擎了解。
子串 {ε, a, b, c, d, ab, bc, cd, abc, bcd, abcd}
真子串 {ε, a, b, c, d, ab, bc, cd, abc, bcd}
子序列 {ε, a, b, c, d, ab, ac, ad, bc, bd, cd, abc, abd, acd, bcd, abcd}
真子序列 {ε, a, b, c, d, ab, ac, ad, bc, bd, cd, abc, abd, acd, bcd}
{ε, a, b, aa, bb, ab, ba, aaa, bbb, aab, aba, babbababbab, …}
a((a|b*)(bb|(cc)*)|dd)
^[\+\-]?(0|0?[1-9][0-9]*)(\.\d+)?([Ee][\+\-]?0?[1-9]+)?$
25x -> 25[0-5]2xx -> 2[0-4]\dany -> [01]?\d\d?number -> 2xx | 25x | anyip -> number . number . number . number
((2[0-4]\d|25[0-5]|[01]?\d\d?).){3}(2[0-4]\d|25[0-5]|[01]?\d\d?)
```
mc mc mc
RegularExpression
这个单词的字符串QAQ
]]>在 Bukkit 中,有一个 org.bukkit.WorldCreator
类,可以用于创建新的世界;而这个类中,有一个名为 generator()
的方法可以提供自定义的地形生成器(这也是下一节将会讲到的东西),如果不提供 generator 的话,Bukkit 将会使用内部的生成器。
Minecraft 原版的世界生成分为两个阶段,Generation 和 Population。我们将会对 Population 阶段进行修改:
世界加载时会触发 WorldInitEvent,这时世界已经加载好了所需的相关设置,即将进行区块生成。我们需要为这个世界添加自定义的 BlockPopulator:
public class ExtendVanillaGenerator implements Listener { @EventHandler public void onInit(WorldInitEvent event) { if ("world".equals(event.getWorld().getName())) { event.getWorld().getPopulators().add(new PumpkinPopulator()); } } private static class PumpkinPopulator extends BlockPopulator { @Override public void populate(World world, Random random, Chunk chunk) { // 随机生成一些南瓜的数量 int amount = random.nextInt(8); for (int i = 0; i < amount; i++) { // 随机位置 int x = random.nextInt(16); int z = random.nextInt(16); for (int y = 255; y >= 0; y--) { if (chunk.getBlock(x, y, z).getType() != Material.AIR) { // 只让南瓜生成在草方块上 if (chunk.getBlock(x, y, z).getType() == Material.GRASS_BLOCK&& chunk.getBlock(x, y + 1, z).getType() == Material.AIR) chunk.getBlock(x, y + 1, z).setType(Material.PUMPKIN); break; } } } } }}
我们想让世界在任何地方都随机生成一些南瓜,那么启动游戏看看效果:
南瓜的确变多了。
Minecraft 原版的所有世界生成的类都在 nms 包内以 WorldGen 开头,可以自行反编译查看他们的实现:
假设你对 Bukkit 插件已经有了一些了解,主类大概看起来是这样的:
public class WorldGenTutor extends JavaPlugin { @Override public ChunkGenerator getDefaultWorldGenerator(String worldName, String id) { return null; }}
覆盖 getDefaultWorldGenerator 方法,然后编辑 bukkit.yml :
worlds: world: generator: 插件名
当然,你也可以为这个世界指定同一个插件的不同生成器(当然你的插件需要根据 id 判断并实现对应功能),将 generator 后编辑为 插件名:id
即可,这里的 id 将会作为上文的方法的第二个 String 参数。
最后,如果你想要控制主世界的生成,你需要在 plugin.yml
中加上 load: startup
。
届时,Bukkit 已经认可你的插件是可以提供世界生成器了,但是目前而言,这个方法仍然返回 null,我们需要给他加上对应的功能。
创建一个新的类,看起来是这样的:
public class FlatGenerator extends ChunkGenerator {}
超平坦应该是这样的:
最下两层为基岩,第三层为草方块,其他什么也不要:
public class FlatGenerator extends ChunkGenerator { @Override public ChunkData generateChunkData(World world, Random random, int x, int z, BiomeGrid biome) { // 创建区块数据 ChunkData chunkData = createChunkData(world); // 一个区块的大小为 16*16,高度为 0-255 // 将这个区块的 (0,0,0) 到 (16,2,16) ,即最低两层填充为基岩 chunkData.setRegion(0, 0, 0, 16, 2, 16, Material.BEDROCK); // 将第三层填充为草方块 chunkData.setRegion(0, 2, 0, 16, 3, 16, Material.GRASS_BLOCK); // 将整个区块的生物群系设置为平原(PLAINS) for (int i = 0; i < 16; i++) { for (int j = 0; j < 16; j++) { biome.setBiome(i, j, Biome.PLAINS); } } return chunkData; }}
ChunkData 类用于存储世界的方块信息,BiomeGrid 用于存储生物群系信息。
这一节就在这里结束了,至此,如果认真阅读了源码,你应该已经了解了如何创建一个 Bukkit 上的地图生成器了。
目前,网络上能找到的各种世界生成器的教程/资源,按照以下的方式生成一个地图:
什么是噪声函数呢?
噪声函数,基本上是一个种子随机发生器。它需要一个数作为参数,然后根据这个参数返回一个随机数。如果两次都传同一个参数进来,它就会产生两次相同的数。这个性质决定了 Minecraft 使用相同的种子总是生成相同的地形,
如果每个相近的参数生成的随机数相差太大,那么 Minecraft 的地形将是无比混乱的。噪声函数在传入连续的数字时,返回的随机数的差值是不大的,整体数值呈现随机但连续起伏。
关于噪声函数,你可以去这里看看。如果你能够硬肛全洋文文档,你也可以去这里看看,这一篇详细的讲解了各种噪声的区别,
那么 Mojang 的生成器是如何运作的呢?
根据土球的答案,我们可以了解到 Minecraft 的地形生成与上文不同,是先生成生物群系,再通过群系来决定地形(如高度)。生成的地形分为两个大阶段,Generation 时期生成主要的地形,Population 时期生成点缀,比如树。
土球回答的下面也有一个答案,那个答案比较详细的讲述了 Minecraft 的地形生成过程。
我们采用地形决定群系的方式。首先是生成地形。常用的噪声函数有 Perlin Noise 和 Simplex,我们采用 Simplex。
public class NormalGenerator extends ChunkGenerator { private SimplexOctaveGenerator noise; @Override public ChunkData generateChunkData(World world, Random random, int chunkX, int chunkZ, BiomeGrid biome) { ChunkData chunkData = createChunkData(world); // 我们需要的噪声生成器 if (noise == null) { noise = new SimplexOctaveGenerator(world.getSeed(), 1); // 我们需要更平缓的地形,所以需要设置 scale // 该值越大,地形变化更大 // 微调即可 noise.setScale(0.005D); } for (int x = 0; x < 16; x++) { for (int z = 0; z < 16; z++) { // 方块的真实坐标 int realX = chunkX * 16 + x; int realZ = chunkZ * 16 + z; // noise 方法返回 -1 到 1 之间的随机数 double noiseValue = noise.noise(realX, realZ, 0.5D, 0.5D); // 将 noise 值放大,作为该点的高度 int height = (int) (noiseValue * 40D + 100D); // 底层基岩 chunkData.setBlock(x, 0, z, Material.BEDROCK); // 中间石头 for (int y = 0; y < height - 1; y++) { chunkData.setBlock(x, y, z, Material.STONE); } // 表面覆盖泥土和草方块 chunkData.setBlock(x, height - 1, z, Material.DIRT); chunkData.setBlock(x, height, z, Material.GRASS_BLOCK); } } return chunkData; }}
有点单调,除了默认生成的生物以外没有任何东西,并且地形看起来也很单调。
如果只是小小地点缀一下地图,BlockPopulator 是再适合不过的选择了。
重写 getDefaultPopulators
方法,返回我们自定义的:树!
@Overridepublic List<BlockPopulator> getDefaultPopulators(World world) { return ImmutableList.of(new TreePopulator());}private static class TreePopulator extends BlockPopulator { @Override public void populate(World world, Random random, Chunk chunk) { // 假设只有 1/4 的区块生成树 if (random.nextInt(4) < 1) { // 假设每个区块生成 1-3 颗树 int amount = random.nextInt(3) + 1; for (int i = 0; i < amount; i++) { // 随机生成树的坐标 int x = random.nextInt(16); int z = random.nextInt(16); int y = 255; // 找到最高的方块来生成树 while (chunk.getBlock(x, y, z).getType() == Material.AIR) y--; // 生成树 world.generateTree(chunk.getBlock(x, y, z).getLocation(), // 搞点有趣的,我们随机选择不同的树生成 TreeType.values()[random.nextInt(TreeType.values().length)]); } } }}
效果拔群。在下面这个实例里,我把 scale 从 0.005 调成了 0.0025,地图变得非常平缓。
(最终还是没有蘑菇树,果然还是不行呢)
地面上不是那么单调了,但是 Minecraft 的特色可不止地面上。现在,我们往这个世界加一点矿物:
@Overridepublic List<BlockPopulator> getDefaultPopulators(World world) { return ImmutableList.of(new TreePopulator(), new DiamondPopulator());}private static class DiamondPopulator extends BlockPopulator { @Override public void populate(World world, Random random, Chunk chunk) { // 假设每个区块只有一个钻石矿 // 钻石矿脉随机生成在高度 16 以下 int x = random.nextInt(16); // 不要生成在基岩上 int y = random.nextInt(15) + 1; int z = random.nextInt(16); // 继续生成的几率 while (random.nextDouble() < 0.8D) { // 只替换岩石 if (chunk.getBlock(x, y, z).getType() == Material.STONE) { chunk.getBlock(x, y, z).setType(Material.DIAMOND_ORE); } // 向某个方向随机继续生成 switch (random.nextInt(6)) { case 0: x++; break; case 1: y++; break; case 2: z++; break; case 3: x--; break; // 不要生成到基岩下面去了 case 4: y = Math.max(y-1, 0); break; default: z--; break; } } }}
经过暴力开采后找到了我们的钻石矿。
你可以仿照这个方法生成其他矿物。
读了上面几章以后,你一定对代码中出现的 PerlinNoiseGenerator / SimplexOctaveGenerator 感到疑惑了。这一章将会教会你 噪声函数 的各种概念,以及如何使用。
如果你能够访问 archive.org 并且可以阅读英文,那么你可以看看这一篇文章。
各位想必用过随机数生成器,即 Java 的 Random 类,虽然这个类很好的满足了我们对于不可预测性的需求,但是它的输出过于随机;在这种情况下,Ken Perlin 发明了 Perlin 噪声函数。Perlin 函数看起来是这样的:
如果传入更连续的参数,最终的结果会是这样的:
噪声函数的形状被我们用于生成地形。
这是一个普通的正弦波
这是一个噪声函数
现在,脑补一个随机的噪声,脑补一下增加/减少它的频率,增加/减少它的振幅。
当振幅减少时,函数将会变「矮」,当频率增加时,函数起伏更加剧烈。
如果我们把低频高幅的函数和高频低幅的函数混合起来:
就会得到和原有单调的噪声函数完全不同的、更为复杂的函数图像:
我们将 噪声函数 定义为 noise(x) ,返回值为 0-1 的小数。
那么上文的混合函数写出来可能就是这样的:
f(x) = 1*noise(x) + 0.5*noise(2x) + 0.25*noise(4x) + 0.125*noise(8x) + 0.0625*noise(16x)
在数学表达式中,我们可以简单的把 2x 4x 8x 里的数字称为频率,将 0.5 0.25 0.125 称为振幅。
到这里,有心的读者可能会注意到了,这个 f(x) 最后的值不是可以大于 1 了,还能叫噪声函数吗?
别担心,记住这个问题,继续往下看。
上文的混合函数可以写成这样:
/*** 假设此函数返回 0-1 之间的随机数,并且满足噪声函数的相关定义** @param x 参数* @return 0-1 的随机数*/static double noise(double x) { return 0;}/*** 噪声函数** @param x 参数* @param freq 频率* @param amp 振幅* @return 函数值*/static double f(double x, double freq, double amp) { return amp * noise(x * freq);}static double f(double x) { return f(x, 1, 1) + f(x, 2, 0.5) + f(x, 4, 0.25) + f(x, 8, 0.125);}
这项技术被称为分形:
维基百科:
分形噪声是上述 Perlin 1985年的文章中提出的将符合上文所述三条件的噪声通过计算分形和构造更复杂效果的算法。
在一维的情况下,设噪声函数为noise(x),则通过 noise(2x), noise(4x) 等就可以构造更高频率的噪声。
你可能也注意到了,分形函数中,我们可以将分形次数提取出来,作为单独的一个参数,这个参数我们称之为 octave。那么上文代码里最后一个方法可以写成这样:
static double f(double x, double freq, double amp, int octaves, boolean normalized) { double result = 0.0D; double a = 1.0D; double f = 1.0D; double max = 0.0D; for (int i = 0; i < octaves; ++i) { result += f(x, f, a); max += amp; f *= freq; a *= amp; } if (normalized) { result /= max; } return result;}
前文代码中最后一个 f()
方法就与目前我们的 f(x, 2, 0.5, 4, false)
的作用相同了,仔细想想,是不是这样?
这 normalized 参数是干啥的?上文中我(也可能是读者你)提出了一个问题,这个参数即是解决这个问题的。仔细想想,是不是这样。
到了这里,我们只剩一个问题了:最开始的代码里的 noise 方法,如何实现呢?
org.bukkit.util.noise 包内有 4 个类,用于提供生成噪声函数的方法。
static double bukkitNoise(double x, double freq, double amp, int octaves, boolean normalized) { SimplexNoiseGenerator generator = new SimplexNoiseGenerator(new Random()); return generator.noise(x, octaves, freq, amp, normalized);}static double bukkitOctave(double x, double freq, double amp, int octaves, boolean normalized) { SimplexOctaveGenerator generator = new SimplexOctaveGenerator(new Random(), octaves); return generator.noise(x, freq, amp, normalized);}
如果你认真的看了上面的所有文章,并且认真的思考过后,你应该已经掌握了这几个类的用处了。
几点需要注意的地方:
最后一个问题:为什么会有 PerlinXxxxGenerator 和 SimplexXxxxGenerator 两种呢?
Perlin 是初代噪声函数,Simplex 基于 Perlin 优化,得到的图像更好看,在高维度的速度也更快。
这个函数的图像是这样的:
你可以用这个函数让山峰更加陡峭,让山谷更加平坦。
n1 = new PerlinNoiseGenerator(random);double e = n1.noise(x * 0.01F, z * 0.01F, 6, 2.0F, 0.5F);e = Math.pow(e, 2.5);elevation[x][z] = (int) (64 + e * 64F);
你可以用此创建锋利的山脊。
function ridgenoise(x, z) { return 2 * (0.5 - abs(0.5 - noise(x, z))); }e0 = 1 * ridgenoise(1 * x, 1 * z);e1 = 0.5 * ridgenoise(2 * x, 2 * z) * e0;e2 = 0.25 * ridgenoise(4 * x, 4 * z) * (e0+e1);e = e0 + e1 + e2;elevation[x][z] = Math.pow(e, 2.5);
本文中所有源码位于 https://github.com/PluginsCDTribe/WorldGenTutor 。
本文部分参考 https://www.redblobgames.com/maps/terrain-from-noise/ 。
作为补充可以阅读 Yaossg 的 1.13 世界生成介绍 https://yaossg.com/blog/1-13-worldgen/ 。
]]>